Laravel предлози: Вметнете потврдени бренд-ресурси со комплети управувани преку API
A proposal generator becomes far more persuasive when every document looks as though it belongs to the client. The trouble is that “use their branding” often means hunting through a website, copying colors by eye, guessing which logo is current, and pasting fragile CSS into a template.
This tutorial replaces that manual work with a production Laravel integration. An authenticated user submits a client’s public website, a queued job calls the Brand Kit Extractor API, and the application validates and stores a versioned brand snapshot containing the brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. Proposal and report templates can then consume that snapshot without making an external request during document generation.
Here, verified means that the data is evidence-based, returned by the extraction service, and validated at our application boundary. It does not establish trademark ownership or grant permission to use a brand.
Get access and create a service token
Start by registering at https://ai.mihajlo.mk/register, or use https://ai.mihajlo.mk/login if you already have an account.
- Open the Brand Kit Extractor service page.
- Choose the 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.
This service requires authentication. It accepts a Bearer token, an X-API-Token header, or a token query parameter. The Laravel implementation below uses a Bearer token because it keeps the credential out of URLs, access logs, and analytics. Regenerating the service token revokes the previously active token, so token rotation must include updating the application environment and restarting workers that cache configuration.
Confirm the endpoint before writing Laravel code
The exact operation is POST https://ai.mihajlo.mk/api/brand-kit-extractor/v1/extract-brand-kit. Its JSON request body contains url. Make one minimal request with a temporary shell variable:
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"}'
Inspect the result without copying it into a fixture or log. It should contain the brand name, logos, colors, fonts, imagery, social profiles, and CSS variables. Their contents remain untrusted input until validated.
Now place the credential in Laravel’s environment-backed configuration:
# .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' => [
'base_url' => 'https://ai.mihajlo.mk/api/brand-kit-extractor',
'token' => env('BRAND_KIT_TOKEN'),
'connect_timeout' => (int) env('BRAND_KIT_CONNECT_TIMEOUT', 3),
'timeout' => (int) env('BRAND_KIT_TIMEOUT', 20),
],
];
Never commit .env. Production should inject the value through its secret manager or deployment environment.
Architecture: extract asynchronously, render locally
Website extraction depends on two networks and may take longer than an ordinary form submission. A queue job therefore fits the feature better than a synchronous controller call.
The flow is deliberately small:
- The controller authorizes access, validates an HTTPS URL, and marks the proposal import as pending.
- A queued job calls a dedicated API client.
- A domain object validates the complete response contract.
- The job atomically stores the snapshot only if it is still the newest import.
- Proposal and report rendering reads local JSON, so document generation remains deterministic if the external service is unavailable.
Give each request an import UUID. If a user submits two websites quickly, an older slow job cannot overwrite the newer result.
Persist the import state
This assumes the everyday proposal generator already has a proposals table and Proposal model. Add the snapshot and its operational state:
<?php
// database/migrations/xxxx_add_brand_kit_to_proposals.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::table('proposals', function (Blueprint $table): void {
$table->json('brand_kit')->nullable();
$table->string('brand_kit_status', 20)->default('empty');
$table->uuid('brand_kit_import_id')->nullable()->index();
$table->text('brand_kit_error')->nullable();
$table->timestamp('brand_kit_imported_at')->nullable();
});
}
public function down(): void
{
Schema::table('proposals', function (Blueprint $table): void {
$table->dropColumn([
'brand_kit',
'brand_kit_status',
'brand_kit_import_id',
'brand_kit_error',
'brand_kit_imported_at',
]);
});
}
};
Add 'brand_kit' => 'array' and 'brand_kit_imported_at' => 'immutable_datetime' to the model’s casts. Keep the new attributes out of broad user-controlled mass assignment.
Validate the API at the boundary
A successful HTTP status is not enough. Upstream deployments can return incomplete JSON, an HTML error page, or a shape your renderer does not understand. The mapper below requires every promised category and verifies that CSS variables are a bounded scalar map.
<?php
// app/Domain/BrandKit/BrandKitData.php
namespace App\Domain\BrandKit;
use Illuminate\Support\Facades\Validator;
use InvalidArgumentException;
final readonly class BrandKitData
{
public function __construct(private array $data) {}
public static function fromApi(array $payload): self
{
$data = Validator::make($payload, [
'brand_name' => ['required', 'string', 'max:255'],
'logos' => ['present', 'array'],
'colors' => ['present', 'array'],
'fonts' => ['present', 'array'],
'imagery' => ['present', 'array'],
'social_profiles' => ['present', 'array'],
'css_variables' => ['present', 'array'],
])->validate();
foreach ($data['css_variables'] as $name => $value) {
$validName = is_string($name)
&& preg_match('/^--[a-z0-9-]{1,100}$/i', $name);
if (! $validName || ! is_scalar($value)
|| strlen((string) $value) > 200) {
throw new InvalidArgumentException(
'Invalid CSS variable in API response.'
);
}
}
return new self($data);
}
public function toArray(): array
{
return $this->data;
}
}
Do not silently substitute empty arrays for missing fields. That would turn an upstream contract regression into an apparently valid, incomplete proposal.
Build a bounded Laravel HTTP client
The client retries only connection failures, rate limits, and server errors. Authentication and validation failures are permanent until configuration or input changes, so retrying them would waste quota.
<?php
// app/Services/BrandKitExtractor.php
namespace App\Services;
use App\Domain\BrandKit\BrandKitData;
use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
final class BrandKitException extends \RuntimeException
{
public function __construct(
string $message,
public readonly bool $retryable = false
) {
parent::__construct($message);
}
}
final class BrandKitExtractor
{
public function extract(string $url): BrandKitData
{
$token = config('services.brand_kit.token');
if (! is_string($token) || $token === '') {
throw new BrandKitException('Service token is not configured.');
}
try {
$response = Http::baseUrl(config('services.brand_kit.base_url'))
->acceptJson()
->asJson()
->withToken($token)
->connectTimeout(config('services.brand_kit.connect_timeout'))
->timeout(config('services.brand_kit.timeout'))
->retry(
2,
fn (int $attempt, Exception $e): int => 250 * $attempt,
function (Exception $e): bool {
if ($e instanceof ConnectionException) {
return true;
}
if (! $e instanceof RequestException) {
return false;
}
$status = $e->response->status();
return $status === 429 || $status >= 500;
},
throw: false
)
->post('/v1/extract-brand-kit', ['url' => $url]);
} catch (ConnectionException $e) {
throw new BrandKitException(
'Extractor connection failed.',
true
);
}
$status = $response->status();
if ($status === 401 || $status === 403) {
throw new BrandKitException('Extractor authentication failed.');
}
if ($status === 429 || $status >= 500) {
throw new BrandKitException('Extractor is temporarily unavailable.', true);
}
if (! $response->successful()) {
throw new BrandKitException('Extractor rejected the request.');
}
if (strlen($response->body()) > 1_000_000) {
throw new BrandKitException('Extractor response is too large.');
}
try {
$payload = $response->json();
if (! is_array($payload)) {
throw new BrandKitException('Extractor returned invalid JSON.');
}
return BrandKitData::fromApi($payload);
} catch (BrandKitException $e) {
throw $e;
} catch (Throwable $e) {
throw new BrandKitException('Extractor contract validation failed.');
}
}
}
This creates at most two HTTP attempts per job execution. A rate limit remains a structured temporary failure rather than an unbounded retry loop.
Queue an idempotent import
<?php
// app/Jobs/ImportProposalBrandKit.php
namespace App\Jobs;
use App\Models\Proposal;
use App\Services\BrandKitException;
use App\Services\BrandKitExtractor;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;
final class ImportProposalBrandKit implements ShouldQueue
{
use Queueable;
public int $tries = 2;
public int $timeout = 50;
public function __construct(
public readonly int $proposalId,
public readonly string $url,
public readonly string $importId
) {}
public function backoff(): array
{
return [60];
}
public function handle(BrandKitExtractor $extractor): void
{
try {
$kit = $extractor->extract($this->url);
} catch (BrandKitException $e) {
Log::warning('Brand kit import failed', [
'proposal_id' => $this->proposalId,
'import_id' => $this->importId,
'retryable' => $e->retryable,
]);
if ($e->retryable) {
throw $e;
}
$this->markFailed($e->getMessage());
return;
}
Proposal::query()
->whereKey($this->proposalId)
->where('brand_kit_import_id', $this->importId)
->update([
'brand_kit' => $kit->toArray(),
'brand_kit_status' => 'ready',
'brand_kit_error' => null,
'brand_kit_imported_at' => now(),
]);
}
public function failed(?Throwable $e): void
{
$this->markFailed('Temporary extractor failure after retries.');
}
private function markFailed(string $message): void
{
Proposal::query()
->whereKey($this->proposalId)
->where('brand_kit_import_id', $this->importId)
->update([
'brand_kit_status' => 'failed',
'brand_kit_error' => $message,
]);
}
}
The logs contain identifiers and failure classification, but not the token, response body, or customer URL. Metrics should count ready, permanent-failure, temporary-failure, and queue-latency outcomes without high-cardinality labels.
Connect the proposal endpoint
<?php
// app/Http/Controllers/ProposalBrandKitController.php
namespace App\Http\Controllers;
use App\Jobs\ImportProposalBrandKit;
use App\Models\Proposal;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
final class ProposalBrandKitController
{
public function store(Request $request, Proposal $proposal): JsonResponse
{
abort_unless($proposal->user_id === $request->user()->id, 403);
$validated = $request->validate([
'url' => ['required', 'url:https', 'max:2048'],
]);
$importId = (string) Str::uuid();
$proposal->forceFill([
'brand_kit_status' => 'pending',
'brand_kit_import_id' => $importId,
'brand_kit_error' => null,
])->save();
ImportProposalBrandKit::dispatch(
$proposal->id,
$validated['url'],
$importId
);
return response()->json([
'status' => 'pending',
'import_id' => $importId,
], 202);
}
}
// routes/web.php
use App\Http\Controllers\ProposalBrandKitController;
use Illuminate\Support\Facades\Route;
Route::post('/proposals/{proposal}/brand-kit', [
ProposalBrandKitController::class,
'store',
])->middleware(['auth', 'throttle:10,1']);
For stricter multi-user applications, replace the ownership check with a proposal policy. Also constrain submitted hosts to domains associated with the proposal when your data model supports that. This prevents authenticated users from spending quota on arbitrary targets.
The renderer should read only a ready snapshot. Escape brand names, validate colors before placing them in CSS, and never inject returned CSS, SVG, or HTML with raw Blade output. Treat remote logo and imagery URLs as untrusted; proxy and inspect them through a separately hardened download path if PDFs require local files.
Test success and authentication failure
Laravel’s HTTP fake keeps tests deterministic and proves the request body and Bearer authentication without contacting the service.
<?php
// tests/Feature/BrandKitExtractorTest.php
namespace Tests\Feature;
use App\Services\BrandKitException;
use App\Services\BrandKitExtractor;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class BrandKitExtractorTest extends TestCase
{
public function test_it_maps_a_complete_brand_kit(): void
{
config(['services.brand_kit.token' => 'test-token']);
Http::fake([
'https://ai.mihajlo.mk/api/brand-kit-extractor/*' =>
Http::response([
'brand_name' => 'Example',
'logos' => [],
'colors' => [],
'fonts' => [],
'imagery' => [],
'social_profiles' => [],
'css_variables' => ['--brand-primary' => '#112233'],
], 200),
]);
$kit = app(BrandKitExtractor::class)
->extract('https://example.com');
$this->assertSame('Example', $kit->toArray()['brand_name']);
Http::assertSent(fn (Request $request): bool =>
$request->hasHeader('Authorization', 'Bearer test-token')
&& $request['url'] === 'https://example.com'
);
}
public function test_it_does_not_retry_bad_credentials(): void
{
config(['services.brand_kit.token' => 'expired-token']);
Http::fake([
'*' => Http::response(['message' => 'Unauthorized'], 401),
]);
try {
app(BrandKitExtractor::class)->extract('https://example.com');
$this->fail('Expected BrandKitException.');
} catch (BrandKitException $e) {
$this->assertFalse($e->retryable);
}
Http::assertSentCount(1);
}
}
Deploy and operate the feature
Run the migration, cache configuration after injecting the production token, and restart queue workers so they receive the new environment. The worker timeout must exceed the job’s 50-second timeout.
php artisan migrate --force
php artisan config:cache
php artisan queue:restart
php artisan test --filter=BrandKitExtractorTest
php artisan queue:work --tries=2 --timeout=60
Use a process supervisor in production; the final command is illustrative, not a replacement for worker supervision. During token rotation, update the secret first and restart workers immediately because regenerating the service token revokes the previous one.
Common failures
- 401 or 403: confirm plan activation, token scope, environment injection, and cached configuration. Do not retry automatically.
- 422 or another request rejection: verify that the submitted URL is public, HTTPS, and correctly formed.
- 429: the account is rate-limited or quota-constrained. Keep the import pending only during bounded retries, then expose a safe retry action.
- 5xx or connection timeout: preserve the existing ready snapshot, retry with the bounded policy, and alert on sustained failures.
- Contract validation failure: do not store partial data. Compare the response with the official documentation before changing the mapper.
- Jobs remain pending: verify that a queue worker is running and using the same deployment configuration as the web process.
Final verification checklist
- The endpoint is exactly the documented
POSTURL and sends only a JSONurl. - The service token exists only in environment-backed configuration.
- Authentication and validation failures are not blindly retried.
- Every promised response category is validated before storage.
- A stale job cannot overwrite a newer import.
- Logs exclude credentials, response bodies, and unnecessary customer data.
- Proposal generation uses the stored ready snapshot and remains available during API downtime.
- Templates escape text and never trust returned CSS, SVG, HTML, or remote asset URLs.
The important result is not merely a successful API call. It is a dependable boundary between an evolving public website and a repeatable document pipeline. Once that boundary is authenticated, validated, observable, and asynchronous, branded proposals stop being a last-minute formatting chore and become an ordinary, reliable part of generating the report.