Tutorials

Laravel Proposals: Automate Brand Asset Imports with the Brand Kit Extractor API

Laravel Proposals: Automate Brand Asset Imports with the Brand Kit Extractor API

A proposal generator can produce flawless pricing tables and still look unfinished when the customer’s logo, colors, and typography arrive as scattered attachments. Manual copying is slow, inconsistent, and particularly awkward when the same brand must appear in proposals, PDF reports, and follow-up documents.

This tutorial builds a production Laravel import pipeline around the Brand Kit Extractor API. A user submits a public website URL, Laravel queues the extraction, validates every required brand-data category, and stores a versioned snapshot that a proposal or report renderer can safely consume.

Here, verified means the response passed the documented application boundary and remains associated with its source URL. It does not mean trademark ownership or legal endorsement has been established.

Get access and test the API

Complete the service onboarding before writing integration code:

  1. Register at https://ai.mihajlo.mk/register, or sign in at https://ai.mihajlo.mk/login.
  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.

This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The implementation below uses the Bearer form because it keeps credentials out of URLs, access logs, and browser history.

Regenerating the service token revokes the previously active token. Treat rotation as a deployment change: update the secret store, redeploy workers and web processes, verify traffic, and only then conclude the rotation.

The exact request is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Send a JSON object containing url. Before building the feature, make one minimal request from a trusted terminal:

curl --fail-with-body \
  --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 commit the token. Put it in Laravel’s environment configuration:

# .env
BRAND_KIT_TOKEN=YOUR_SERVICE_TOKEN

Add the service definition to config/services.php. Environment access stays in configuration so cached production configuration behaves predictably.

'brand_kit' => [
    'token' => env('BRAND_KIT_TOKEN'),
    'base_url' => 'https://ai.mihajlo.mk/api/brand-kit-extractor',
],

Choose a small, durable architecture

The import should be asynchronous. Website extraction depends on an external network, can outlive a normal browser request, and may be throttled. The web request therefore records intent and dispatches a queue job. A dedicated client owns HTTP behavior, while a domain mapper rejects incomplete or oversized responses before storage.

The resulting flow is:

  1. The controller accepts a public website URL and creates a pending import.
  2. A queue job calls the extraction endpoint.
  3. The mapper validates the brand name, logos, colors, fonts, imagery, social profiles, and CSS variables.
  4. The job atomically stores a ready snapshot or a structured failure.
  5. The proposal generator reads only snapshots whose status is ready.

A request identifier prevents an older queued job from overwriting a newer import for the same website. That small safeguard matters when a user clicks “refresh brand” twice.

Create the persistence boundary

Start with a Laravel application configured with a supported database and queue backend. PHP 8.3 or newer, Composer, and a working queue worker are prerequisites. Generate the basic classes with first-party commands:

php artisan make:model BrandKit -m
php artisan make:controller BrandKitImportController
php artisan make:job ExtractBrandKit
php artisan make:test BrandKitClientTest
php artisan queue:table
php artisan migrate

Use a hash for uniqueness rather than indexing a potentially long URL. The JSON snapshot preserves the evidence returned by the service without forcing unstable nested data into relational columns.

<?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_kits', function (Blueprint $table): void {
            $table->id();
            $table->string('source_hash', 64)->unique();
            $table->text('source_url');
            $table->uuid('request_id');
            $table->string('status', 20)->index();
            $table->string('brand_name')->nullable();
            $table->json('assets')->nullable();
            $table->string('failure_code', 40)->nullable();
            $table->text('failure_message')->nullable();
            $table->timestamps();
        });
    }

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

In app/Models/BrandKit.php, allow only these application-owned fields and cast the snapshot:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class BrandKit extends Model
{
    protected $fillable = [
        'source_hash', 'source_url', 'request_id', 'status',
        'brand_name', 'assets', 'failure_code', 'failure_message',
    ];

    protected function casts(): array
    {
        return ['assets' => 'array'];
    }
}

Build a strict API client

Keep transport failures separate from permanent failures. Timeouts, HTTP 429, and server errors may succeed later. Authentication failures and rejected requests should not be retried blindly.

<?php

namespace App\Services;

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

final class RetryableBrandKitFailure extends RuntimeException {}
final class PermanentBrandKitFailure extends RuntimeException {}

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

        if ($token === '') {
            throw new PermanentBrandKitFailure('Service token is not configured.');
        }

        try {
            $response = Http::baseUrl(config('services.brand_kit.base_url'))
                ->withToken($token)
                ->acceptJson()
                ->asJson()
                ->connectTimeout(5)
                ->timeout(25)
                ->post('/v1/extract-brand-kit', ['url' => $url]);
        } catch (ConnectionException $exception) {
            throw new RetryableBrandKitFailure(
                'Connection to the extraction service failed.',
                previous: $exception
            );
        }

        if ($response->status() === 429 || $response->serverError()) {
            throw new RetryableBrandKitFailure(
                'Extraction service is temporarily unavailable.'
            );
        }

        if ($response->status() === 401 || $response->status() === 403) {
            throw new PermanentBrandKitFailure(
                'The service token was rejected.'
            );
        }

        if ($response->clientError()) {
            throw new PermanentBrandKitFailure(
                'The extraction request was rejected.'
            );
        }

        $payload = $response->json();

        if (! is_array($payload)) {
            throw new RetryableBrandKitFailure(
                'Extraction service returned invalid JSON.'
            );
        }

        return BrandKitData::fromApi($payload)->toArray();
    }
}

Validate the domain response

The boundary below requires the seven supplied response categories and applies local safety limits. It does not guess undocumented logo or font subfields. If the official documentation defines deeper item schemas, add those rules here rather than scattering assumptions through templates.

<?php

namespace App\Services;

use Illuminate\Support\Facades\Validator;
use JsonException;

final readonly class BrandKitData
{
    private 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 $payload): self
    {
        $validator = Validator::make($payload, [
            'brand_name' => ['required', 'string', 'max:200'],
            'logos' => ['required', 'array', 'max:100'],
            'colors' => ['required', 'array', 'max:100'],
            'fonts' => ['required', 'array', 'max:100'],
            'imagery' => ['required', 'array', 'max:200'],
            'social_profiles' => ['required', 'array', 'max:100'],
            'css_variables' => ['required', 'array', 'max:300'],
        ]);

        if ($validator->fails()) {
            throw new PermanentBrandKitFailure(
                'Brand-kit response failed contract validation.'
            );
        }

        $data = $validator->validated();

        try {
            $encoded = json_encode($data, JSON_THROW_ON_ERROR);
        } catch (JsonException $exception) {
            throw new PermanentBrandKitFailure(
                'Brand-kit response is not valid JSON data.',
                previous: $exception
            );
        }

        if (strlen($encoded) > 1_000_000) {
            throw new PermanentBrandKitFailure(
                'Brand-kit response exceeds the local storage limit.'
            );
        }

        return new self(
            $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,
        ];
    }
}

Queue the extraction safely

The job retries only transient failures and uses bounded backoff. It records the final failure without exposing tokens, response bodies, or potentially sensitive headers in logs.

<?php

namespace App\Jobs;

use App\Models\BrandKit;
use App\Services\BrandKitClient;
use App\Services\PermanentBrandKitFailure;
use App\Services\RetryableBrandKitFailure;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

final class ExtractBrandKit implements ShouldQueue
{
    use Queueable;

    public int $tries = 4;
    public int $timeout = 40;

    public function __construct(
        public int $brandKitId,
        public string $requestId,
    ) {}

    public function handle(BrandKitClient $client): void
    {
        $kit = BrandKit::findOrFail($this->brandKitId);

        if ($kit->request_id !== $this->requestId) {
            return;
        }

        try {
            $assets = $client->extract($kit->source_url);
        } catch (RetryableBrandKitFailure $exception) {
            Log::warning('Brand-kit extraction deferred', [
                'brand_kit_id' => $kit->id,
                'attempt' => $this->attempts(),
            ]);

            if ($this->attempts() >= $this->tries) {
                $this->markFailed($kit, 'temporary_failure');
                return;
            }

            $delays = [10, 30, 90];
            $this->release($delays[$this->attempts() - 1]);
            return;
        } catch (PermanentBrandKitFailure $exception) {
            Log::notice('Brand-kit extraction rejected', [
                'brand_kit_id' => $kit->id,
            ]);
            $this->markFailed($kit, 'permanent_failure');
            return;
        }

        $kit->refresh();

        if ($kit->request_id !== $this->requestId) {
            return;
        }

        $kit->update([
            'status' => 'ready',
            'brand_name' => $assets['brand_name'],
            'assets' => $assets,
            'failure_code' => null,
            'failure_message' => null,
        ]);
    }

    private function markFailed(BrandKit $kit, string $code): void
    {
        $kit->update([
            'status' => 'failed',
            'failure_code' => $code,
            'failure_message' => 'Brand assets could not be imported.',
        ]);
    }
}

Accept imports from the proposal application

The controller permits only HTTP and HTTPS URLs, blocks obvious local targets, and replaces the request identifier on refresh. For a private multi-tenant product, also authorize access to the proposal and consider restricting imports to customer-approved domains.

<?php

namespace App\Http\Controllers;

use App\Jobs\ExtractBrandKit;
use App\Models\BrandKit;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

final class BrandKitImportController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $url = $request->validate([
            'url' => ['required', 'url:http,https', 'max:2048'],
        ])['url'];

        $host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
        $blockedName = $host === 'localhost' || str_ends_with($host, '.local');
        $blockedIp = filter_var($host, FILTER_VALIDATE_IP)
            && ! filter_var(
                $host,
                FILTER_VALIDATE_IP,
                FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
            );

        abort_if($host === '' || $blockedName || $blockedIp, 422);

        $requestId = (string) Str::uuid();

        $kit = BrandKit::updateOrCreate(
            ['source_hash' => hash('sha256', $url)],
            [
                'source_url' => $url,
                'request_id' => $requestId,
                'status' => 'pending',
                'brand_name' => null,
                'assets' => null,
                'failure_code' => null,
                'failure_message' => null,
            ]
        );

        ExtractBrandKit::dispatch($kit->id, $requestId)->afterCommit();

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

Register the authenticated route in routes/web.php or routes/api.php:

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

Route::post('/brand-kits/import', [BrandKitImportController::class, 'store'])
    ->middleware('auth');

The proposal renderer should query a kit with status = ready, escape text and URLs, and map only explicitly supported asset shapes into its view model. Never paste returned CSS variables directly into a stylesheet. Validate custom-property names and constrain values before rendering; external content can otherwise become a CSS injection surface.

Test the contract without calling production

Laravel’s HTTP fake makes authentication, request shape, and failure classification deterministic.

<?php

namespace Tests\Feature;

use App\Services\BrandKitClient;
use App\Services\PermanentBrandKitFailure;
use App\Services\RetryableBrandKitFailure;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

final class BrandKitClientTest extends TestCase
{
    public function test_it_imports_a_complete_brand_kit(): 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',
                    'logos' => [], 'colors' => [], 'fonts' => [],
                    'imagery' => [], 'social_profiles' => [],
                    'css_variables' => [],
                ], 200),
        ]);

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

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

        Http::assertSent(fn ($request) =>
            $request->method() === 'POST'
            && $request['url'] === 'https://example.com'
            && $request->hasHeader(
                'Authorization',
                'Bearer test-token'
            )
        );
    }

    public function test_rate_limits_are_retryable(): void
    {
        config(['services.brand_kit.token' => 'test-token']);
        Http::fake(fn () => Http::response([], 429));

        $this->expectException(RetryableBrandKitFailure::class);

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

    public function test_missing_fields_are_rejected(): void
    {
        config(['services.brand_kit.token' => 'test-token']);
        Http::fake(fn () => Http::response([
            'brand_name' => 'Incomplete',
        ], 200));

        $this->expectException(PermanentBrandKitFailure::class);

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

Deploy and operate the importer

Run migrations before admitting imports, cache production configuration after injecting the token, and restart long-lived workers so they receive the new environment:

php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan test

Supervise php artisan queue:work with the platform’s process manager. Set worker timeouts above the job’s 40-second timeout, but keep them bounded. Monitor counts of pending, ready, temporary-failure, and permanent-failure imports, as well as job duration and queue age. Alert on sustained 429 responses or rising server failures rather than logging complete provider responses.

Common failure patterns

  • Every request returns 401 or 403: confirm plan activation and the service-scoped token. A regenerated token immediately invalidates the old one.
  • Development works but production rejects authentication: clear and rebuild Laravel’s configuration cache after updating the secret.
  • Imports remain pending: verify that the configured queue worker is running and watching the correct queue connection.
  • Responses fail validation: compare the boundary mapper with the official documentation. Do not silently store partial data.
  • Frequent 429 responses: reduce concurrent workers or import frequency. Preserve bounded backoff instead of creating an aggressive retry loop.
  • PDF rendering breaks: keep extraction data separate from the rendering view model, and validate individual asset URLs, font formats, colors, and CSS values before use.

Final verification checklist

  • The exact POST endpoint receives JSON containing only the intended public url.
  • The service token exists only in environment-backed configuration and secret management.
  • Successful responses contain and validate all seven required brand-data categories.
  • Authentication and validation failures are not retried.
  • Connections, responses, retries, payload size, and queue execution are bounded.
  • Stale jobs cannot overwrite newer imports.
  • Logs contain identifiers and failure classes, never credentials or response bodies.
  • The proposal and report generator reads only ready snapshots and safely maps external assets.
  • Tests pass with Http::fake(), without consuming quota or contacting the live service.

The visible result is simple: enter a customer’s website and receive a coherent brand kit for the next proposal. The engineering underneath is deliberately less glamorous—strict boundaries, durable jobs, restrained retries, safe rendering, and observable failures. Those details are what turn an appealing API demonstration into a dependable everyday feature.

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.