Vodiči

Laravel Brand Kit Extraction for Instant Landing Page Drafts

Izdvajanje Laravel kompleta brenda za trenutačne nacrte odredišne stranice

A customer pastes their website into onboarding. A few moments later, your application has a reviewable landing-page theme draft: brand name, visual assets, color evidence, typography, imagery, social links, and CSS variables. The tempting implementation is a controller that calls an API and saves whatever comes back. The production implementation is more deliberate.

This tutorial builds the latter in Laravel: queued extraction, strict boundary validation, bounded retries, structured failures, safe storage, deterministic tests, and an explicit approval boundary before extracted values can affect rendered CSS.

Get access to the Brand Kit Extractor

First, register an account, or use the sign-in page if you already have one.

  1. Open the Brand Kit Extractor service page.
  2. Choose an available Free, Plus, or Pro plan and complete its 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 Laravel configuration, as shown shortly.

Regenerating the service token revokes the previously active token, so coordinate rotation with deployment. This service is not tokenless: calls must authenticate with a Bearer token, an X-API-Token header, or a token query parameter. We will use the Bearer form because it keeps credentials out of URLs and access logs.

Confirm the exact HTTP contract

The operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON request body contains one field, url. Before writing Laravel code, make a minimal request from a secure terminal:

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

Compare the response with the official documentation. The application boundary must require the documented brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. It should reject missing, malformed, or unexpectedly large data rather than silently saving a partial kit.

Now place the credential in the project environment. Never commit the real value:

# .env
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN
BRAND_KIT_CONNECT_TIMEOUT=3
BRAND_KIT_TIMEOUT=20
<?php
// config/services.php

return [
    // Existing services...

    'brand_kit' => [
        'endpoint' => 'https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit',
        'token' => env('BRAND_KIT_TOKEN'),
        'connect_timeout' => (int) env('BRAND_KIT_CONNECT_TIMEOUT', 3),
        'timeout' => (int) env('BRAND_KIT_TIMEOUT', 20),
    ],
];

Choose a small, resilient architecture

Brand extraction performs remote network work and may exceed a comfortable browser-request budget. A queue job therefore serves a real purpose: onboarding returns immediately while a worker performs extraction.

The feature has four boundaries:

  • The controller validates the submitted public website and creates a pending record.
  • A queued job controls lifecycle, retries, logging, and failure status.
  • A dedicated client owns authentication, timeouts, and HTTP error classification.
  • A mapper validates external JSON and produces application-owned draft data.

The draft remains unapproved. Extracted CSS is evidence, not trusted executable content. A later review step can promote selected, sanitized design tokens into a published theme.

A compact project structure is sufficient: app/Http/Controllers/BrandDraftController.php, app/Jobs/ExtractBrandKit.php, app/Services/BrandKitClient.php, app/Services/BrandKitMapper.php, and app/Models/BrandDraft.php.

Persist lifecycle state and evidence

Create the model and migration with php artisan make:model BrandDraft -m. The JSON column keeps the validated response categories together, while status and error code remain queryable.

<?php
// database/migrations/..._create_brand_drafts_table.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_drafts', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('source_url', 2048);
            $table->string('status', 20)->default('pending');
            $table->json('kit')->nullable();
            $table->string('error_code', 50)->nullable();
            $table->boolean('approved')->default(false);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('brand_drafts');
    }
};

// app/Models/BrandDraft.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class BrandDraft extends Model
{
    protected $fillable = [
        'user_id', 'source_url', 'status', 'kit', 'error_code', 'approved',
    ];

    protected function casts(): array
    {
        return [
            'kit' => 'array',
            'approved' => 'boolean',
        ];
    }
}

Validate the response at the boundary

The supplied contract defines the required categories but does not justify guessing undocumented inner fields. The mapper consequently enforces the top-level contract, bounds collection sizes and nesting, rejects dangerous URL schemes, and treats CSS variables as stored evidence rather than renderable CSS.

<?php
// app/Services/BrandKitMapper.php

namespace App\Services;

use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use RuntimeException;

final class BrandKitMapper
{
    public function map(array $payload): array
    {
        $validated = Validator::make($payload, [
            'brand_name' => ['required', 'string', 'max:160'],
            'logos' => ['required', 'array', 'max:20'],
            'colors' => ['required', 'array', 'max:100'],
            'fonts' => ['required', 'array', 'max:50'],
            'imagery' => ['required', 'array', 'max:50'],
            'social_profiles' => ['required', 'array', 'max:30'],
            'css_variables' => ['required', 'array', 'max:100'],
        ])->validate();

        foreach ($validated as $field => $value) {
            $this->assertBoundedJson($value, $field);
        }

        return $validated;
    }

    private function assertBoundedJson(
        mixed $value,
        string $path,
        int $depth = 0
    ): void {
        if ($depth > 5) {
            throw ValidationException::withMessages([
                $path => 'Brand data is nested too deeply.',
            ]);
        }

        if (is_array($value)) {
            foreach ($value as $key => $child) {
                $this->assertBoundedJson(
                    $child,
                    $path.'.'.(string) $key,
                    $depth + 1
                );
            }

            return;
        }

        if (! is_string($value) && ! is_int($value)
            && ! is_float($value) && ! is_bool($value)
            && $value !== null) {
            throw new RuntimeException("Unsupported value at {$path}");
        }

        if (is_string($value)) {
            if (strlen($value) > 2048 || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $value)) {
                throw ValidationException::withMessages([
                    $path => 'Brand data contains an invalid string.',
                ]);
            }

            $scheme = parse_url($value, PHP_URL_SCHEME);

            if (is_string($scheme)
                && ! in_array(strtolower($scheme), ['http', 'https'], true)) {
                throw ValidationException::withMessages([
                    $path => 'Brand data contains an unsafe URL scheme.',
                ]);
            }
        }
    }
}

If the official documentation specifies an envelope around these fields, unwrap that documented envelope in the client before calling the mapper. Do not add speculative aliases that could turn an upstream breaking change into corrupted data.

Build the HTTP client with bounded retries

Laravel’s built-in HTTP client provides deterministic timeouts, retries, and test fakes. Retry connection failures, HTTP 429 responses, and temporary server failures. Do not retry authentication failures, ordinary client errors, or validation failures.

<?php
// app/Services/BrandKitClient.php

namespace App\Services;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;

final class BrandKitFailure extends RuntimeException
{
    public function __construct(
        public readonly string $failureCode,
        public readonly bool $retryable,
        string $message
    ) {
        parent::__construct($message);
    }
}

final class BrandKitClient
{
    public function __construct(private BrandKitMapper $mapper) {}

    public function extract(string $url): array
    {
        $token = config('services.brand_kit.token');

        if (! is_string($token) || $token === '') {
            throw new BrandKitFailure(
                'configuration_error',
                false,
                'Brand Kit service token is not configured.'
            );
        }

        try {
            $response = Http::withToken($token)
                ->acceptJson()
                ->asJson()
                ->connectTimeout(config('services.brand_kit.connect_timeout'))
                ->timeout(config('services.brand_kit.timeout'))
                ->retry(
                    [250, 750],
                    when: function (Throwable $exception): bool {
                        if ($exception instanceof ConnectionException) {
                            return true;
                        }

                        return $exception instanceof RequestException
                            && ($exception->response->status() === 429
                                || $exception->response->serverError());
                    },
                    throw: false
                )
                ->post(config('services.brand_kit.endpoint'), ['url' => $url]);
        } catch (ConnectionException $exception) {
            throw new BrandKitFailure(
                'network_error',
                true,
                'Brand Kit service could not be reached.'
            );
        }

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

        if ($response->status() === 429) {
            throw new BrandKitFailure('rate_limited', true, 'Service rate limit reached.');
        }

        if ($response->serverError()) {
            throw new BrandKitFailure('upstream_error', true, 'Service returned a temporary error.');
        }

        if (! $response->successful()) {
            throw new BrandKitFailure('request_rejected', false, 'Service rejected the request.');
        }

        $payload = $response->json();

        if (! is_array($payload)) {
            throw new BrandKitFailure('invalid_response', false, 'Service returned invalid JSON.');
        }

        return $this->mapper->map($payload);
    }
}

The delays produce at most three attempts during one job execution. Laravel reevaluates the retry predicate for each failure, so a 401 or 422 returns immediately. The application also avoids logging response bodies, which may contain customer-specific evidence.

Connect onboarding to the queue

The controller rejects credentials in URLs, non-HTTP schemes, localhost, and IP-literal targets. The upstream service is still responsible for securely fetching public websites; local validation is an additional onboarding guard, not a substitute for server-side request-forgery defenses at the fetcher.

<?php
// app/Http/Controllers/BrandDraftController.php

namespace App\Http\Controllers;

use App\Jobs\ExtractBrandKit;
use App\Models\BrandDraft;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class BrandDraftController
{
    public function store(Request $request): JsonResponse
    {
        $data = $request->validate(['url' => ['required', 'url', 'max:2048']]);
        $parts = parse_url($data['url']);

        abort_unless(
            isset($parts['scheme'], $parts['host'])
            && in_array(strtolower($parts['scheme']), ['http', 'https'], true)
            && ! isset($parts['user'], $parts['pass'])
            && strtolower($parts['host']) !== 'localhost'
            && filter_var($parts['host'], FILTER_VALIDATE_IP) === false,
            422,
            'Enter a public website hostname.'
        );

        $draft = BrandDraft::create([
            'user_id' => $request->user()->id,
            'source_url' => $data['url'],
            'status' => 'pending',
        ]);

        ExtractBrandKit::dispatch($draft->id);

        return response()->json(['id' => $draft->id, 'status' => 'pending'], 202);
    }

    public function show(Request $request, BrandDraft $draft): JsonResponse
    {
        abort_unless($draft->user_id === $request->user()->id, 404);

        return response()->json($draft->only(
            'id', 'status', 'kit', 'error_code', 'approved'
        ));
    }
}

// routes/web.php

use App\Http\Controllers\BrandDraftController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function (): void {
    Route::post('/onboarding/brand-draft', [BrandDraftController::class, 'store']);
    Route::get('/onboarding/brand-draft/{draft}', [BrandDraftController::class, 'show']);
});
<?php
// app/Jobs/ExtractBrandKit.php

namespace App\Jobs;

use App\Models\BrandDraft;
use App\Services\BrandKitClient;
use App\Services\BrandKitFailure;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;

final class ExtractBrandKit implements ShouldQueue
{
    use Queueable;

    public int $tries = 2;
    public array $backoff = [60];

    public function __construct(public readonly int $draftId) {}

    public function handle(BrandKitClient $client): void
    {
        $draft = BrandDraft::findOrFail($this->draftId);
        $draft->update(['status' => 'processing', 'error_code' => null]);

        try {
            $kit = $client->extract($draft->source_url);
            $draft->update(['status' => 'ready', 'kit' => $kit]);
        } catch (BrandKitFailure $failure) {
            if ($failure->retryable) {
                throw $failure;
            }

            $draft->update([
                'status' => 'failed',
                'error_code' => $failure->failureCode,
            ]);
        }
    }

    public function failed(?Throwable $failure): void
    {
        BrandDraft::whereKey($this->draftId)->update([
            'status' => 'failed',
            'error_code' => $failure instanceof BrandKitFailure
                ? $failure->failureCode
                : 'internal_error',
        ]);

        Log::warning('Brand extraction exhausted retries', [
            'draft_id' => $this->draftId,
            'failure_type' => $failure ? $failure::class : null,
        ]);
    }
}

Test without calling the service

Http::fake() keeps tests fast and proves the exact method, endpoint, authentication, and request body. Add separate mapper tests for missing categories, excessive nesting, and unsafe schemes.

<?php
// tests/Feature/BrandKitClientTest.php

namespace Tests\Feature;

use App\Services\BrandKitClient;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class BrandKitClientTest extends TestCase
{
    public function test_it_extracts_and_validates_a_brand_kit(): void
    {
        config()->set('services.brand_kit.token', 'test-token');

        Http::fake([
            config('services.brand_kit.endpoint') => Http::response([
                'brand_name' => 'Example',
                'logos' => [],
                'colors' => ['#112233'],
                'fonts' => ['Inter'],
                'imagery' => [],
                'social_profiles' => [],
                'css_variables' => ['--brand-primary' => '#112233'],
            ], 200),
        ]);

        $kit = app(BrandKitClient::class)->extract('https://example.com');

        $this->assertSame('Example', $kit['brand_name']);

        Http::assertSent(function (Request $request): bool {
            return $request->method() === 'POST'
                && $request->url() === config('services.brand_kit.endpoint')
                && $request->hasHeader('Authorization', 'Bearer test-token')
                && $request['url'] === 'https://example.com';
        });
    }

    public function test_it_does_not_retry_authentication_failures(): void
    {
        config()->set('services.brand_kit.token', 'expired-token');
        Http::fake([
            config('services.brand_kit.endpoint') => Http::response([], 401),
        ]);

        try {
            app(BrandKitClient::class)->extract('https://example.com');
        } finally {
            Http::assertSentCount(1);
        }
    }
}

Deploy, observe, and operate it

Deploy the migration and cached configuration, then run a queue worker under your process supervisor:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan queue:work --queue=default --tries=2 --timeout=45

Keep the worker timeout above the HTTP timeout but below the queue connection’s retry interval. During token rotation, update the environment secret, rebuild configuration cache, and restart workers. Because regeneration revokes the previous token, avoid leaving old workers alive with cached credentials.

Monitor counts and durations for pending, ready, failed, rate-limited, authentication, network, and invalid-response outcomes. Alert on sustained failures rather than one temporary upstream error. Logs should contain draft identifiers and failure classifications, never tokens, authorization headers, complete response bodies, or unnecessary customer data.

Common failures

  • Authentication errors: verify plan activation, token scope, cached configuration, and whether the token was regenerated.
  • Rate limiting: let bounded backoff run, reduce onboarding concurrency, and review the active plan. Do not create an unbounded retry loop.
  • Invalid responses: compare the current documented schema with the mapper. Reject drift until deliberately supported.
  • Queue jobs never run: confirm the worker, queue connection, failed-jobs storage, and process supervisor are active.
  • Unsafe previews: escape brand text and asset attributes, proxy or constrain remote assets as your application requires, and never concatenate extracted CSS variables into a style block before approval and property-specific sanitization.

Final verification checklist

  • The exact POST endpoint receives JSON containing url.
  • The service token comes only from environment-backed configuration.
  • Connection and total response times are bounded.
  • Only connection failures, 429 responses, and server errors are retried.
  • Every required brand category is validated before storage.
  • Draft ownership is enforced on status reads.
  • Extracted content remains unapproved and cannot directly execute as CSS.
  • Tests use Http::fake() and make no external requests.
  • Workers, configuration cache, metrics, logs, and token rotation are covered operationally.

The important result is not merely that onboarding can recognize a brand. It is that a fallible external observation becomes a controlled application artifact: authenticated, bounded, validated, attributable, reviewable, and safe to evolve. That is the difference between an impressive demo and a landing-page draft feature you can confidently put in front of customers.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.