answear/luigis-box-bundle
Symfony bundle for integrating Luigi's Box search and content updates. Configure public/private keys, hosts, timeouts, cache TTL, multiple or dynamic configs and custom headers, then send content updates and search requests through simple services.
This is a Symfony bundle, not a Laravel package — there is no LuigisBoxServiceProvider, no facade, no extra.laravel.providers. Plan for one of two paths:
Resources/config/*.xml into your own provider once. This is the realistic path for a Laravel monolith.Minimal steps for path 2:
composer require answear/luigis-box-bundle
Check the version matrix first — 5.x requires PHP 8.4 + Symfony 7, 4.x is PHP 8.2 + Symfony 6/7, 3.x is Symfony 5.4/6. Pin deliberately.
Move the bundle config into config/services.php:
'luigis_box' => [
'default_config' => 'main',
'configs' => [
'main' => [
'host' => env('LUIGIS_BOX_HOST', 'https://live.luigisbox.com'),
'publicKey' => env('LUIGIS_BOX_PUBLIC_KEY'),
'privateKey' => env('LUIGIS_BOX_PRIVATE_KEY'),
'connectionTimeout' => 4.0,
'requestTimeout' => 10.0,
'searchTimeout' => 6.0,
'searchCacheTtl' => 0,
],
],
],
Bind the singletons in a provider — ConfigProvider first, then the request services that consume it:
$this->app->singleton(ConfigProvider::class, fn ($app) =>
new ConfigProvider($app['config']->get('services.luigis_box'))
);
$this->app->singleton(RequestInterface::class, ...); // content updates
$this->app->singleton(SearchRequestInterface::class, ...); // search
$this->app->singleton(UpdateByQueryRequest::class, ...);
Where to look first: Service\ConfigProvider (all credentials/headers/state), Service\RequestInterface (content sync), Service\SearchRequestInterface (search), then ValueObject\* for the payload shapes and Response\* for what comes back. The README is the only doc; there is no published API reference beyond the Luigi's Box upstream docs.
First use case: push your catalog into Luigi's Box and read it back. Build a ContentUpdateCollection, call $request->contentUpdate($collection), assert $apiResponse->success, then wire a SearchUrlBuilder to a /search endpoint and return $response->hits.
Catalog sync via model events + queues. Don't call the API inline from saved/deleted observers. Dispatch a job, build the collection inside the job, and keep batches well under the per-request item limit:
class SyncProductsToLuigisBox implements ShouldQueue
{
public function handle(RequestInterface $request): void
{
Product::query()->chunkById(500, function ($products) use ($request) {
$collection = new ContentUpdateCollection(
$products->map(fn (Product $p) => new ContentUpdate(
$p->name,
"/p/{$p->slug}",
'product',
[
'title' => $p->name,
'price' => $p->price,
'brand' => $p->brand->name,
'category' => $p->category->path,
'availability' => $p->in_stock,
]
))->all()
);
$response = $request->contentUpdate($collection);
if (! $response->success) { /* log $response->errors, optionally retry */ }
});
}
}
Prefer partial updates for hot fields. PartialContentUpdate with just ['price' => ...] is much cheaper than a full document rewrite when only price/stock moves. Reserve ContentUpdate for new objects or full reindexes.
Availability is its own verb. changeAvailability(new ContentAvailability($url, $isAvailable)) accepts a single object or a ContentAvailabilityCollection — use the scalar form for one-off toggles in an admin action, the collection for a bulk stock import.
Removals need explicit urls. ContentRemoval($url, $type). When a product is deleted, enqueue the removal job before the row disappears (or capture url/type in the event payload), otherwise you lose the identifiers.
Search endpoint pattern. Build the SearchUrlBuilder from the incoming request, don't let the frontend dictate raw filters:
$builder = (new SearchUrlBuilder((int) $request->input('page', 1)))
->setQuery((string) $request->input('q'))
->addFilter('type', 'product');
foreach ((array) $request->input('brand', []) as $brand) {
$builder->addFilter('brand', $brand);
}
foreach ((array) $request->input('prefer', []) as $p) {
$builder->addPrefer('brand', $p);
}
if ($request->filled('sort')) {
$builder->setSort($request->input('sort'), $request->input('dir', 'asc'));
}
if ($request->filled('client_id')) {
$builder->setClientId($request->input('client_id')); // 5.2+: analytics attribution
}
$response = $searchRequest->search($builder);
Return $response->hits, $response->facets, $response->totalHits, $response->correctedQuery. Hit::$attributes is the field bag you pushed during indexing — hydrate thin view models from it rather than re-querying Eloquent per hit.
Facets are the merchandising surface. Facet::$name, $type, $values map directly to your filter sidebar. Dynamic facets (3.1+) let you request facet sets contextually; keep the facet names aligned with the field names you index, or the sidebar silently comes back empty.
Multi-store / multi-tenant via named configs. Define configs.store_de and configs.store_pl, and switch with $configProvider->setConfig('store_de') at the start of the request/job. For per-tenant credentials loaded at runtime (not in services.php), use addConfig('tenant_42', new ConfigDTO(...)).
Bulk re-merchandising with update-by-query. Use UpdateByQuery\Search($types, $fields) + UpdateByQuery\Update($updateFields), keep the $jobId, then poll getStatus($jobId) until $response->completed is true and read okCount/errorsCount. Do the polling in a scheduled command with backoff, not in a web request.
Enable search caching for read-heavy pages. searchCacheTtl (seconds, hard cap 300 — that's Luigi's Box's limit) kills repeated identical queries on category pages. Leave it at 0 for personalized/prefer-heavy queries where staleness hurts.
Test against the interfaces. Resolve SearchRequestInterface from the container in your feature tests and swap it for a fake returning a hand-built SearchResponse. Same for RequestInterface around sync jobs — you should never hit the live API in CI.
ConfigProvider is mutable, shared state. setConfig(), setHeader(), resetHeaders() mutate the singleton. In Octane, queue workers, or any long-lived process, a header or config set for one job leaks into the next. Always resetHeaders() and re-setConfig() at the top of each job, or resolve a fresh provider per unit of work.setHeader() feeds search requests (2.2+); don't assume content-update calls carry them. getRequestHeaders() is deprecated — use getAuthorizationHeaders() if you need the auth ones.connectionTimeout/requestTimeout govern content pushes; searchTimeout (default 6s) governs search; recommendations got their own timeouts in 4.2. A search that "hangs" while indexing is fast usually means you tuned only requestTimeout. Set searchTimeout below your PHP-FPM/queue timeout.ApiResponse::$success === false on partial success. This is the #1 silent-data-loss trap: 480 of 500 docs index, $success is false, $okCount is 480, $errors holds 20 ApiResponseError objects with $url, $type, $reason, $causedBy. Log $errors keyed by $url and re-queue only those urls. Never branch on $success alone for "did anything work".TooManyRequestsException::$retryAfterSeconds → release the job with that delay.TooManyItemsException::$limit → split the collection and retry; don't retry the same payload.ServiceUnavailableException → back off and retry.BadRequestException exposes $request/$response → the payload is wrong; retrying is pointless, fix the mapping.MalformedResponseException exposes $response → API contract drift; log it, it's a canary.
Catch them separately; a blanket catch (\Throwable) around content updates will spin forever on BadRequestException.ContentUpdate argument is not always the title. If $fields contains a title key, that wins. Index a title field when your display name differs from the indexed title, and don't be surprised when the constructor arg seems ignored.searchCacheTtl above 300 is not honored — Luigi's Box caps it. Validate the config at boot rather than discovering it in production.SearchResponse::$query can be null (e.g. filtered-only browsing with no q). Type-hint/handle null in your controllers and templates; older code that assumed a string will break on a version bump.guid in responses (5.3+) and clientId in search (5.2+). Use clientId to attribute traffic in Luigi's Box analytics, and guid to correlate a response hit back to your own aggregate if you push internal ids as a field.ApiResponse::$rawResponse on failures, log the exception's $request/$response payloads, and wrap the request services in a small decorator that times and logs each verb. That decorator is also your cleanest extension point — bind your decorator to RequestInterface and keep the bundle class as the inner call.ValueObject constructors, the ConfigProvider mutation API, and interface-level substitution. There is no event system, no pluggable serializer, no retry middleware. If you need richer behavior, decorate at the interface.How can I help you explore Laravel packages today?