Native PHP 8.3: Autopopulate New Client Workspaces with Brand Kit Extractor API
A new client workspace should feel ready on first login, not like an empty form asking someone to transcribe their own website. A practical onboarding flow can accept the client’s public URL, extract its visual identity, validate the result, and prefill the workspace with usable logos, colors, and fonts.
This tutorial builds that flow in Native PHP 8.3 using cURL, a strict domain boundary, bounded retries, atomic storage, structured errors, and deterministic PHPUnit tests. The Brand Kit Extractor API remains isolated behind one client, so controllers and storage code never depend on transport details or unvalidated remote JSON.
Get access and copy a service-scoped token
Start by creating an account at https://ai.mihajlo.mk/register. If you already have one, sign in at https://ai.mihajlo.mk/login.
- Open the Brand Kit Extractor service page.
- Choose the available Free, Plus, or Pro plan that fits your expected usage, then complete activation.
- Open the official service documentation.
- Find the Service token panel and copy the service-scoped token.
- Store it in environment-backed project configuration, never in PHP source code.
This service is not tokenless: every extraction request must authenticate using 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 routine access logs.
Regenerating the service token revokes the previously active token. Treat regeneration as a credential rotation: update the deployment secret, restart or redeploy every process that reads it, verify a request with the new token, and only then consider the rollout complete.
Confirm the HTTP contract before writing the feature
The exact operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON request body contains url. Run one minimal request with a public site you are authorized to process:
curl --request POST \
--url https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Accept: application/json" \
--header "Content-Type: application/json" \
--data '{"url":"https://example.com"}'
Do not pipe this through verbose debugging in shared terminals or CI because command arguments and headers can be captured. The real application will send the same method, endpoint, headers, and body while keeping the token in its environment.
Lay out the Native PHP project
The design is deliberately small. A transport handles cURL, an API client owns retries and response validation, a provisioner applies domain rules, and a controller translates application failures into HTTP responses. Extraction remains synchronous because workspace creation needs the result immediately; if onboarding latency later becomes unacceptable, the provisioner is the seam to move behind a job runner.
brand-workspace/
├── .env
├── .env.example
├── composer.json
├── public/
│ └── create-workspace.php
├── src/
│ ├── BrandKit.php
│ ├── BrandKitClient.php
│ ├── CurlTransport.php
│ ├── Transport.php
│ └── WorkspaceProvisioner.php
├── storage/
│ └── workspaces/
└── tests/
└── BrandKitClientTest.php
Require PHP 8.3 and cURL, configure PSR-4 autoloading, and install PHPUnit 11 for development:
{
"require": {
"php": "^8.3",
"ext-curl": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"Workspace\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Workspace\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit tests"
}
}
composer install
composer dump-autoload
cp .env.example .env
# Edit .env locally, then export it before starting PHP:
set -a
. ./.env
set +a
php -S 127.0.0.1:8080 -t public
Native PHP does not load .env automatically. The shell commands above are suitable for local development; production should inject variables through its process manager, container runtime, or secret store. Exclude .env from version control.
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_CONNECT_TIMEOUT=3
BRAND_KIT_RESPONSE_TIMEOUT=20
WORKSPACE_STORAGE=/absolute/path/to/brand-workspace/storage/workspaces
Build a narrow, replaceable cURL transport
The transport returns status, headers, and body without interpreting brand data. This separation gives tests a deterministic fake and keeps cURL-specific behavior out of the domain layer.
<?php
// src/Transport.php
namespace Workspace;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
interface Transport
{
public function post(
string $url,
array $headers,
string $body,
float $connectTimeout,
float $responseTimeout,
): HttpResponse;
}
final class TransportException extends \RuntimeException {}
final class ApiException extends \RuntimeException {}
<?php
// src/CurlTransport.php
namespace Workspace;
final class CurlTransport implements Transport
{
public function post(
string $url,
array $headers,
string $body,
float $connectTimeout,
float $responseTimeout,
): HttpResponse {
$handle = curl_init($url);
if ($handle === false) {
throw new TransportException('Could not initialize cURL');
}
$responseHeaders = [];
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => (int) ($connectTimeout * 1000),
CURLOPT_TIMEOUT_MS => (int) ($responseTimeout * 1000),
CURLOPT_HEADERFUNCTION => static function (
\CurlHandle $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);
},
]);
$bodyResult = curl_exec($handle);
if ($bodyResult === false) {
throw new TransportException(curl_error($handle));
}
return new HttpResponse(
(int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
$responseHeaders,
$bodyResult,
);
}
}
Validate remote data at the application boundary
The service returns a brand name plus logos, colors, fonts, imagery, social profiles, and CSS variables. Remote JSON must not become trusted workspace state merely because it decoded successfully. The DTO requires every contract section, rejects wrong types, and preserves the evidence-based arrays without guessing at their internal shape.
<?php
// src/BrandKit.php
namespace Workspace;
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
{
if (!isset($data['brand_name'])
|| !is_string($data['brand_name'])
|| trim($data['brand_name']) === '') {
throw new ApiException('invalid_response: brand_name');
}
$arrayFields = [
'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
];
foreach ($arrayFields as $field) {
if (!array_key_exists($field, $data) || !is_array($data[$field])) {
throw new ApiException('invalid_response: ' . $field);
}
}
return new self(
trim($data['brand_name']),
$data['logos'],
$data['colors'],
$data['fonts'],
$data['imagery'],
$data['social_profiles'],
$data['css_variables'],
);
}
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,
];
}
}
The client makes at most three attempts. It retries network failures, 429, and server failures with bounded backoff. Authentication, other client errors, malformed JSON, and schema failures are not retried because repetition cannot repair them.
<?php
// src/BrandKitClient.php
namespace Workspace;
final class BrandKitClient
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit';
private \Closure $sleep;
private \Closure $log;
public function __construct(
private Transport $transport,
private string $token,
private float $connectTimeout = 3.0,
private float $responseTimeout = 20.0,
?\Closure $sleep = null,
?\Closure $log = null,
) {
if (trim($token) === '') {
throw new \InvalidArgumentException('Missing BRAND_KIT_TOKEN');
}
$this->sleep = $sleep
?? static fn(int $milliseconds) => usleep($milliseconds * 1000);
$this->log = $log ?? static function (array $event): void {};
}
public function extract(string $url): BrandKit
{
$payload = json_encode(
['url' => $url],
JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->post(
self::ENDPOINT,
[
'Authorization: Bearer ' . $this->token,
'Accept: application/json',
'Content-Type: application/json',
],
$payload,
$this->connectTimeout,
$this->responseTimeout,
);
} catch (TransportException $exception) {
if ($attempt === 3) {
throw new ApiException(
'network_failure',
0,
$exception
);
}
$this->backoff($attempt, 'network');
continue;
}
if ($response->status === 401 || $response->status === 403) {
throw new ApiException('authentication_failed');
}
if ($response->status === 429
|| $response->status >= 500) {
if ($attempt === 3) {
throw new ApiException(
$response->status === 429
? 'quota_or_rate_limit'
: 'upstream_unavailable'
);
}
$this->backoff($attempt, (string) $response->status);
continue;
}
if ($response->status < 200 || $response->status >= 300) {
throw new ApiException(
'request_rejected:' . $response->status
);
}
try {
$decoded = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException $exception) {
throw new ApiException('invalid_json', 0, $exception);
}
if (!is_array($decoded)) {
throw new ApiException('invalid_response');
}
return BrandKit::fromApi($decoded);
}
throw new ApiException('unreachable_failure');
}
private function backoff(int $attempt, string $reason): void
{
$delay = min(1000, 250 * (2 ** ($attempt - 1)));
($this->log)([
'event' => 'brand_kit_retry',
'attempt' => $attempt,
'reason' => $reason,
'delay_ms' => $delay,
]);
($this->sleep)($delay);
}
}
Prefill the workspace atomically
The provisioner accepts only HTTPS URLs with a normal hostname and rejects IP literals and localhost. For an open signup product, add an approved-domain workflow or stronger DNS controls as well; URL syntax validation alone cannot prevent every private-network or DNS-rebinding scenario. Send only public websites the client is entitled to process.
<?php
// src/WorkspaceProvisioner.php
namespace Workspace;
final class WorkspaceProvisioner
{
public function __construct(
private BrandKitClient $client,
private string $storageDirectory,
) {}
public function provision(string $workspaceId, string $siteUrl): BrandKit
{
if (!preg_match('/\A[a-zA-Z0-9_-]{1,64}\z/', $workspaceId)) {
throw new \InvalidArgumentException('Invalid workspace ID');
}
$parts = parse_url($siteUrl);
$host = is_array($parts) ? ($parts['host'] ?? '') : '';
if (filter_var($siteUrl, FILTER_VALIDATE_URL) === false
|| ($parts['scheme'] ?? '') !== 'https'
|| $host === ''
|| strtolower($host) === 'localhost'
|| filter_var($host, FILTER_VALIDATE_IP) !== false) {
throw new \InvalidArgumentException('Use a public HTTPS URL');
}
$kit = $this->client->extract($siteUrl);
$target = $this->storageDirectory . '/' . $workspaceId . '.json';
$temporary = tempnam($this->storageDirectory, 'brand-');
if ($temporary === false) {
throw new \RuntimeException('Could not create temporary file');
}
try {
$json = json_encode([
'workspace_id' => $workspaceId,
'source_url' => $siteUrl,
'brand_kit' => $kit->toArray(),
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
if (file_put_contents($temporary, $json, LOCK_EX) === false
|| !rename($temporary, $target)) {
throw new \RuntimeException('Could not store workspace');
}
} finally {
if (is_file($temporary)) {
unlink($temporary);
}
}
return $kit;
}
}
The public controller must sit behind your application’s authentication, authorization, and CSRF protection. It returns stable failure codes while logging operational context without credentials, response bodies, or the extracted brand content.
<?php
// public/create-workspace.php
declare(strict_types=1);
use Workspace\{
ApiException, BrandKitClient, CurlTransport, WorkspaceProvisioner
};
require dirname(__DIR__) . '/vendor/autoload.php';
header('Content-Type: application/json');
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
throw new RuntimeException('method_not_allowed');
}
$input = json_decode(
file_get_contents('php://input'),
true,
512,
JSON_THROW_ON_ERROR
);
$logger = static function (array $event): void {
error_log(json_encode($event, JSON_THROW_ON_ERROR));
};
$client = new BrandKitClient(
new CurlTransport(),
getenv('BRAND_KIT_TOKEN') ?: '',
(float) (getenv('BRAND_KIT_CONNECT_TIMEOUT') ?: 3),
(float) (getenv('BRAND_KIT_RESPONSE_TIMEOUT') ?: 20),
null,
$logger,
);
$provisioner = new WorkspaceProvisioner(
$client,
getenv('WORKSPACE_STORAGE') ?: ''
);
$kit = $provisioner->provision(
(string) ($input['workspace_id'] ?? ''),
(string) ($input['url'] ?? ''),
);
http_response_code(201);
echo json_encode([
'status' => 'ready',
'brand_kit' => $kit->toArray(),
], JSON_THROW_ON_ERROR);
} catch (InvalidArgumentException | JsonException $exception) {
http_response_code(422);
echo json_encode(['status' => 'invalid_input']);
} catch (ApiException $exception) {
error_log(json_encode([
'event' => 'brand_kit_failure',
'code' => $exception->getMessage(),
]));
http_response_code(503);
echo json_encode([
'status' => 'extraction_failed',
'code' => $exception->getMessage(),
]);
} catch (Throwable $exception) {
error_log(json_encode(['event' => 'workspace_failure']));
http_response_code(500);
echo json_encode(['status' => 'internal_error']);
}
Test retries and mapping without network calls
A fake transport makes failure sequences exact and fast. This test proves that a rate-limited first attempt is retried and that the validated result is mapped correctly.
<?php
// tests/BrandKitClientTest.php
namespace Workspace\Tests;
use PHPUnit\Framework\TestCase;
use Workspace\{
BrandKitClient, HttpResponse, Transport
};
final class FakeTransport implements Transport
{
public int $calls = 0;
public function __construct(private array $responses) {}
public function post(
string $url,
array $headers,
string $body,
float $connectTimeout,
float $responseTimeout,
): HttpResponse {
return $this->responses[$this->calls++];
}
}
final class BrandKitClientTest extends TestCase
{
public function testRetriesRateLimitAndMapsBrandKit(): void
{
$valid = json_encode([
'brand_name' => 'Example',
'logos' => [['url' => 'https://example.com/logo.svg']],
'colors' => [['value' => '#112233']],
'fonts' => [['family' => 'Example Sans']],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--brand-primary' => '#112233'],
], JSON_THROW_ON_ERROR);
$transport = new FakeTransport([
new HttpResponse(429, [], '{}'),
new HttpResponse(200, [], $valid),
]);
$client = new BrandKitClient(
$transport,
'test-token',
sleep: static function (int $milliseconds): void {}
);
$kit = $client->extract('https://example.com');
self::assertSame(2, $transport->calls);
self::assertSame('Example', $kit->brandName);
self::assertSame('#112233', $kit->colors[0]['value']);
}
}
composer test
curl --request POST \
--url http://127.0.0.1:8080/create-workspace.php \
--header "Content-Type: application/json" \
--data '{"workspace_id":"client_acme","url":"https://example.com"}'
Operate the integration in production
Create the storage directory during deployment and grant write access only to the PHP process identity. In a database-backed application, replace the JSON repository with a transaction that updates the new workspace only after validation succeeds. Keep the API client and DTO unchanged.
Monitor extraction latency, success counts, retry counts, failure codes, and the age of workspaces stuck in a pending state. Never log the Authorization header, token, raw response, or full customer URL when its path may contain sensitive data. A normalized hostname and an internal correlation ID are usually enough for diagnosis.
Common failures have distinct remedies: authentication_failed means checking activation and token rotation; quota_or_rate_limit means respecting plan capacity or deferring work; request_rejected points to input or contract problems; invalid_json and invalid_response indicate an upstream contract mismatch that should alert operators rather than pollute stored workspaces.
Final verification checklist
- The token comes from environment-backed configuration and never appears in source control or logs.
- The application sends JSON with
urlto the exact POST endpoint using Bearer authentication. - Only public HTTPS website URLs reach the extraction client.
- Brand name, logos, colors, fonts, imagery, social profiles, and CSS variables are validated before storage.
- Network, rate-limit, and server failures receive bounded retries; authentication and validation failures do not.
- Workspace writes are atomic, the destination is writable, and failures return structured states.
- PHPUnit tests pass with a deterministic fake transport and no live service dependency.
- A real onboarding request produces a ready workspace whose logo, palette, and fonts are already available to the client.
The visible result is pleasantly simple: a client supplies a website and opens a workspace that already resembles their brand. The engineering underneath should be equally disciplined. A narrow API boundary, defensive mapping, deliberate retries, safe storage, and observable failures turn a convenient extraction call into a dependable onboarding feature.