Laravel Onboarding: Build Landing Page Drafts with Brand Kit Extractor API
Onboarding often stalls at the same awkward moment: the customer has a website, but your application still needs a logo, color palette, typography, and enough visual context to create a credible first draft. Asking people to re-enter all of that information creates friction. Copying it manually creates support work.
A better workflow is to extract evidence from the customer’s public website, validate it at the application boundary, and save it as an unapproved landing-page theme draft. The customer gets a useful starting point without allowing remote data to become executable HTML or CSS.
This tutorial builds that workflow with PHP 8.3+, Laravel’s HTTP client, a queued extraction job, defensive response mapping, and deterministic tests. The Brand Kit Extractor API remains the central integration: it turns a public website’s visual identity into structured brand data covering the brand name, logos, colors, fonts, imagery, social profiles, and CSS variables.
Get access before writing integration code
This service requires authentication; it is not a tokenless API. Create an account at the registration page, or use the sign-in page if you already have one.
- Open the Brand Kit Extractor service page.
- Choose an available Free, Plus, or Pro plan and complete its activation.
- Open the official service documentation.
- Find the Service token panel and copy the service-scoped token.
- Place the token in your project’s environment configuration. Never commit it to source control.
Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update every environment that uses the token, rebuild cached configuration, restart workers, and verify the integration.
Confirm the endpoint with a minimal request
The exact request is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit, with a JSON body containing url. The service accepts a Bearer token, an X-API-Token header, or a token query parameter. This project uses Bearer authentication because query-string credentials can leak into access logs and monitoring systems.
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://customer.example"}'
Run this only with a public website you are authorized to process. A successful response should contain the seven documented brand-data areas. Do not assume that success alone makes the values safe to render.
Store the credential in Laravel configuration
Add the secret and queue selection to .env:
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
QUEUE_CONNECTION=database
Add this entry inside the array returned by config/services.php:
'brand_kit' => [
'base_url' => 'https://ai.mihajlo.mk/api/brand-kit-extractor',
'token' => env('BRAND_KIT_TOKEN'),
],
Keeping the fixed service URL in configuration makes tests easy while ensuring the production code calls the supplied endpoint. Only the credential comes from the environment.
Architecture: extraction is evidence, not published design
The browser submits a website URL to an authenticated Laravel route. The controller applies basic public-URL checks, creates a pending draft, and dispatches a queue job. The job calls a dedicated API client, maps the response into a domain object, and stores validated evidence. A later review action can approve selected values for publication.
Background execution is worthwhile here because a remote website must be inspected and the request may encounter throttling or temporary upstream failures. Returning 202 Accepted keeps onboarding responsive, while the database record supplies an explicit pending, processing, retrying, completed, or failed state.
The important trade-off is deliberate: this implementation stores the complete validated evidence tree, but only separately validated CSS variables are eligible for a theme preview. It never inserts remote markup, downloads returned assets, or publishes the result automatically.
app/
Data/BrandKitData.php
Exceptions/BrandKitApiException.php
Http/Controllers/OnboardingBrandDraftController.php
Jobs/ExtractBrandKit.php
Models/BrandThemeDraft.php
Services/BrandKitClient.php
config/services.php
database/migrations/..._create_brand_theme_drafts_table.php
routes/web.php
tests/Feature/ExtractBrandKitTest.php
Create the persistent draft
Generate the model, migration, controller, and job. If your database queue tables do not already exist, generate those too.
php artisan make:model BrandThemeDraft -m
php artisan make:controller OnboardingBrandDraftController
php artisan make:job ExtractBrandKit
php artisan make:queue-table
php artisan migrate
The draft migration records both lifecycle state and sanitized output. Keeping failure details separate prevents an upstream error body from being exposed to customers.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('brand_theme_drafts', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('status', 24)->default('pending');
$table->string('source_url', 2048);
$table->string('brand_name', 200)->nullable();
$table->json('evidence')->nullable();
$table->json('css_variables')->nullable();
$table->string('failure_code', 64)->nullable();
$table->text('failure_message')->nullable();
$table->timestamp('approved_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('brand_theme_drafts');
}
};
In app/Models/BrandThemeDraft.php, add JSON and timestamp casts:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class BrandThemeDraft extends Model
{
protected function casts(): array
{
return [
'evidence' => 'array',
'css_variables' => 'array',
'approved_at' => 'immutable_datetime',
];
}
}
Validate the API response at the boundary
The documented categories are required, but their nested evidence should still be treated as untrusted JSON. The mapper below bounds depth and total size, requires the expected top-level types, and gives CSS variables stricter treatment because they may eventually enter a style declaration.
<?php
namespace App\Data;
use UnexpectedValueException;
final readonly class BrandKitData
{
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(mixed $payload): self
{
if (! is_array($payload)) {
throw new UnexpectedValueException('Response is not a JSON object.');
}
$requiredArrays = [
'logos', 'colors', 'fonts', 'imagery',
'social_profiles', 'css_variables',
];
if (! isset($payload['brand_name'])
|| ! is_string($payload['brand_name'])
|| trim($payload['brand_name']) === ''
|| mb_strlen($payload['brand_name']) > 200) {
throw new UnexpectedValueException('Invalid brand name.');
}
foreach ($requiredArrays as $field) {
if (! array_key_exists($field, $payload) || ! is_array($payload[$field])) {
throw new UnexpectedValueException("Invalid {$field} field.");
}
}
$nodes = 0;
foreach ($requiredArrays as $field) {
self::assertBoundedJson($payload[$field], 0, $nodes);
}
$css = [];
foreach ($payload['css_variables'] as $name => $value) {
if (! is_string($name)
|| ! preg_match('/^--[a-zA-Z0-9_-]{1,80}$/', $name)
|| ! is_string($value)
|| mb_strlen($value) > 200
|| preg_match('/url\s*\(|expression\s*\(|[<>;{}]/i', $value)) {
throw new UnexpectedValueException('Unsafe CSS variable.');
}
$css[$name] = $value;
}
return new self(
trim($payload['brand_name']),
$payload['logos'],
$payload['colors'],
$payload['fonts'],
$payload['imagery'],
$payload['social_profiles'],
$css,
);
}
private static function assertBoundedJson(
mixed $value,
int $depth,
int &$nodes
): void {
if ($depth > 6 || ++$nodes > 1000) {
throw new UnexpectedValueException('Brand evidence is too large.');
}
if (is_array($value)) {
foreach ($value as $child) {
self::assertBoundedJson($child, $depth + 1, $nodes);
}
return;
}
if (! is_null($value)
&& ! is_string($value)
&& ! is_int($value)
&& ! is_float($value)
&& ! is_bool($value)) {
throw new UnexpectedValueException('Unsupported evidence value.');
}
if (is_string($value) && mb_strlen($value) > 4096) {
throw new UnexpectedValueException('Evidence value is too long.');
}
}
}
This is validation, not semantic approval. A syntactically valid logo URL may still reference an unexpected host, and a font name may not be licensed for redistribution. Keep previews escaped, proxy no assets by default, and require customer confirmation before publishing.
Build a bounded, retry-aware API client
Create an exception that carries a stable application reason, HTTP status, retry delay, and retryability flag. Then isolate all transport behavior in BrandKitClient.
<?php
namespace App\Exceptions;
use RuntimeException;
final class BrandKitApiException extends RuntimeException
{
public function __construct(
public readonly string $reason,
public readonly ?int $status = null,
public readonly ?int $retryAfter = null,
public readonly bool $retryable = false,
) {
parent::__construct($reason);
}
}
<?php
namespace App\Services;
use App\Data\BrandKitData;
use App\Exceptions\BrandKitApiException;
use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use UnexpectedValueException;
final class BrandKitClient
{
public function extract(string $url): BrandKitData
{
$token = config('services.brand_kit.token');
if (! is_string($token) || $token === '') {
throw new BrandKitApiException('configuration_error');
}
try {
$response = Http::baseUrl(config('services.brand_kit.base_url'))
->withToken($token)
->acceptJson()
->asJson()
->connectTimeout(3)
->timeout(25)
->retry(
[250, 750, 1500],
function (Exception $exception): bool {
if ($exception instanceof ConnectionException) {
return true;
}
return $exception instanceof RequestException
&& in_array(
$exception->response->status(),
[429, 500, 502, 503, 504],
true
);
},
throw: false
)
->post('/v1/extract-brand-kit', ['url' => $url]);
} catch (ConnectionException) {
throw new BrandKitApiException(
'connection_failure',
retryable: true
);
}
if ($response->status() === 429) {
$header = $response->header('Retry-After');
$delay = ctype_digit((string) $header) ? (int) $header : 60;
throw new BrandKitApiException(
'rate_limited',
429,
min(max($delay, 30), 900),
true
);
}
if (in_array($response->status(), [401, 403], true)) {
throw new BrandKitApiException(
'authentication_failed',
$response->status()
);
}
if ($response->status() === 422) {
throw new BrandKitApiException('request_rejected', 422);
}
if (! $response->successful()) {
throw new BrandKitApiException(
'upstream_failure',
$response->status(),
retryable: $response->serverError()
);
}
try {
return BrandKitData::fromApi($response->json());
} catch (UnexpectedValueException $exception) {
throw new BrandKitApiException(
'invalid_response',
$response->status()
);
}
}
}
The client retries only connection failures, throttling, and selected server errors. Authentication and validation failures are deterministic; retrying them wastes quota and delays useful feedback. Timeouts are bounded, and the response body never enters a log or customer-facing error.
Queue extraction and expose the onboarding route
The job is unique per draft, supports delayed recovery from rate limits, and stores a generic terminal error. For ordinary transient failures, Laravel’s queue retry policy supplies the longer backoff.
<?php
namespace App\Jobs;
use App\Exceptions\BrandKitApiException;
use App\Models\BrandThemeDraft;
use App\Services\BrandKitClient;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;
final class ExtractBrandKit implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public int $tries = 5;
public int $uniqueFor = 1800;
public array $backoff = [60, 300, 900];
public function __construct(public readonly int $draftId) {}
public function uniqueId(): string
{
return (string) $this->draftId;
}
public function handle(BrandKitClient $client): void
{
$draft = BrandThemeDraft::findOrFail($this->draftId);
if ($draft->status === 'completed') {
return;
}
$draft->status = 'processing';
$draft->save();
try {
$kit = $client->extract($draft->source_url);
} catch (BrandKitApiException $exception) {
Log::warning('Brand extraction failed', [
'draft_id' => $draft->id,
'source_host' => parse_url($draft->source_url, PHP_URL_HOST),
'reason' => $exception->reason,
'status' => $exception->status,
]);
if ($exception->reason === 'rate_limited'
&& $this->attempts() < $this->tries) {
$draft->status = 'retrying';
$draft->save();
$this->release($exception->retryAfter ?? 60);
return;
}
if ($exception->retryable && $this->attempts() < $this->tries) {
throw $exception;
}
$draft->status = 'failed';
$draft->failure_code = $exception->reason;
$draft->failure_message = 'Brand extraction could not be completed.';
$draft->save();
return;
}
$draft->brand_name = $kit->brandName;
$draft->evidence = [
'logos' => $kit->logos,
'colors' => $kit->colors,
'fonts' => $kit->fonts,
'imagery' => $kit->imagery,
'social_profiles' => $kit->socialProfiles,
];
$draft->css_variables = $kit->cssVariables;
$draft->status = 'completed';
$draft->failure_code = null;
$draft->failure_message = null;
$draft->save();
}
public function failed(?Throwable $exception): void
{
BrandThemeDraft::whereKey($this->draftId)->update([
'status' => 'failed',
'failure_code' => 'retry_exhausted',
'failure_message' => 'Brand extraction could not be completed.',
]);
}
}
The controller rejects local and private IP literals. In a mature onboarding flow, also compare the submitted host with the customer’s already verified website domain. Do not let this endpoint become a general-purpose URL submission surface.
<?php
namespace App\Http\Controllers;
use App\Jobs\ExtractBrandKit;
use App\Models\BrandThemeDraft;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
final class OnboardingBrandDraftController extends Controller
{
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'url' => ['required', 'url:http,https', 'max:2048'],
]);
$host = strtolower((string) parse_url($validated['url'], PHP_URL_HOST));
$reservedName = $host === 'localhost' || str_ends_with($host, '.local');
$privateIp = filter_var($host, FILTER_VALIDATE_IP)
&& ! filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ($host === '' || $reservedName || $privateIp) {
throw ValidationException::withMessages([
'url' => 'Use an authorized public website URL.',
]);
}
$draft = new BrandThemeDraft();
$draft->user_id = $request->user()->id;
$draft->source_url = $validated['url'];
$draft->status = 'pending';
$draft->save();
ExtractBrandKit::dispatch($draft->id)->afterCommit();
return response()->json([
'id' => $draft->id,
'status' => $draft->status,
], 202);
}
}
Register the authenticated, rate-limited route in routes/web.php:
use App\Http\Controllers\OnboardingBrandDraftController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth', 'throttle:10,1'])->post(
'/onboarding/brand-drafts',
[OnboardingBrandDraftController::class, 'store']
);
Test without calling the real service
Laravel’s Http::fake() makes the transport deterministic. This feature test verifies the exact URL, Bearer authentication, JSON request, domain mapping, and persistence.
<?php
namespace Tests\Feature;
use App\Jobs\ExtractBrandKit;
use App\Models\BrandThemeDraft;
use App\Models\User;
use App\Services\BrandKitClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class ExtractBrandKitTest extends TestCase
{
use RefreshDatabase;
public function test_it_stores_a_validated_theme_draft(): void
{
config(['services.brand_kit.token' => 'test-token']);
Http::fake([
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
=> Http::response([
'brand_name' => 'Example Studio',
'logos' => ['https://customer.example/logo.svg'],
'colors' => ['#123456'],
'fonts' => ['Inter'],
'imagery' => [],
'social_profiles' => [],
'css_variables' => [
'--brand-primary' => '#123456',
],
], 200),
]);
$draft = new BrandThemeDraft();
$draft->user_id = User::factory()->create()->id;
$draft->source_url = 'https://customer.example';
$draft->status = 'pending';
$draft->save();
(new ExtractBrandKit($draft->id))
->handle(app(BrandKitClient::class));
$draft->refresh();
$this->assertSame('completed', $draft->status);
$this->assertSame('Example Studio', $draft->brand_name);
$this->assertSame(
'#123456',
$draft->css_variables['--brand-primary']
);
Http::assertSent(fn ($request) =>
$request->url() ===
'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit'
&& $request->hasHeader(
'Authorization',
'Bearer test-token'
)
&& $request->data() === [
'url' => 'https://customer.example',
]
);
}
}
Add focused client tests for a malformed response, a 401 that is attempted once, a retried 503, a bounded 429 delay, and a connection exception. Also test controller rejection of localhost, private IP literals, unauthenticated requests, and oversized URLs.
Security, observability, and deployment
Never place the token in JavaScript, fixtures, screenshots, exception messages, or logs. Redact authorization headers in your observability platform. Log the draft identifier, source host, stable failure reason, status, attempt count, and duration—not the token, full response, or URL query string.
Render the brand name through Blade’s escaped output. Do not convert evidence into raw HTML. Apply CSS variables only to a sandboxed preview after validation, preferably against a product-owned allowlist of supported variable names. Returned logo, imagery, and social URLs should remain inert links until separately checked and approved.
During deployment, provide BRAND_KIT_TOKEN through the platform’s secret manager, run php artisan migrate --force, then run php artisan config:cache. Start a supervised worker such as php artisan queue:work --tries=5 --timeout=60. After rotating the token or deploying client changes, run php artisan queue:restart so long-lived workers reload configuration.
Alert on sustained increases in authentication_failed, rate_limited, invalid_response, and retry_exhausted. A single throttled request is routine; a fleet-wide authentication failure usually signals token rotation or stale cached configuration.
Common failures
- 401 or 403: confirm plan activation, the service-scoped token, and cached configuration. Do not retry blindly.
- 422: verify that the body contains a valid public
urland that the customer website is reachable. - 429: honor
Retry-After, retain the draft, and resume through the queue. - Timeouts or 5xx responses: use bounded retries and queue backoff; never hold the onboarding browser request open.
- Invalid response: fail closed. Preserve no partial theme, and investigate contract drift without logging the complete payload.
- Draft never progresses: confirm the queue worker is running and inspecting the same queue and environment as the web application.
Final verification checklist
- The account and Free, Plus, or Pro plan are active, and the service token comes from the documentation page’s Service token panel.
- The application sends exactly one JSON
urlto the documented POST endpoint using Bearer authentication. - Connection and response timeouts are bounded, and only transient failures are retried.
- Brand name, logos, colors, fonts, imagery, social profiles, and CSS variables are validated before storage.
- Remote content remains an unapproved draft; no markup or asset is automatically published.
- Tests use
Http::fake()and never consume quota or contain a real token. - Workers, logs, alerts, secret rotation, and cached configuration are covered by deployment operations.
The best onboarding automation does not pretend extraction is judgment. It removes blank-page work while keeping publication authority with the customer. By treating the API response as evidence, enforcing a narrow boundary, and making failure states visible, Laravel can turn an existing website into a useful landing-page draft without turning convenience into a security shortcut.