haringsrob/icecat
A Laravel wrapper around the Icecat product content API. Retrieve detailed product specifications, descriptions, images, and related data for brands and products, making it easy to enrich your catalog with Icecat's open content.
Install and get credentials first. The package is a thin wrapper, not a framework integration, so there is almost nothing to configure — the real setup cost is the Icecat account.
composer require haringsrob/icecat
Register for an Open Icecat account at icecat.biz (free for shops/resellers). You get a username and password; Open Icecat also typically wants a shop name for statistics. Put those in .env, never in code:
ICECAT_USERNAME=your-user
ICECAT_PASSWORD=your-pass
ICECAT_LANGUAGE=EN
Read the source before writing anything. The whole package is one small class in vendor/haringsrob/icecat/src. Open it, count the public methods (usually a product lookup by GTIN/EAN/MPN plus a brand or category lookup), and note the constructor signature and return type. Method names and whether it returns an array, an object, or raw XML/JSON vary between versions — five minutes here saves an hour of guessing. composer show haringsrob/icecat tells you the installed version; pin it in composer.json.
Wire it into the container once. There is no service provider or config file shipped, so create them:
// config/services.php
'icecat' => [
'username' => env('ICECAT_USERNAME'),
'password' => env('ICECAT_PASSWORD'),
'language' => env('ICECAT_LANGUAGE', 'EN'),
],
// app/Providers/AppServiceProvider.php (register)
$this->app->singleton(IcecatClient::class, fn () => new IcecatClient(
config('services.icecat.username'),
config('services.icecat.password'),
));
Adjust the constructor call to match what you read in src/. In practice most developers end up binding their own wrapper class, not the package class directly (see below).
First use case: enriching a catalog. You have a products table with an ean column and no copy, images, or specs. Run one product through the API in php artisan tinker, dump the response, and map the fields you actually need — title, brand, description, image URLs, spec key/value pairs.
Wrap it, don't sprinkle it. The package class should appear in exactly one place in your codebase. Define a domain interface and one implementation:
interface ProductContentProvider
{
public function findByGtin(string $gtin, string $locale = 'EN'): ?ProductContent;
}
final class IcecatContentProvider implements ProductContentProvider
{
public function __construct(private IcecatClient $client) {}
public function findByGtin(string $gtin, string $locale = 'EN'): ?ProductContent
{
try {
$raw = $this->client->getProduct($gtin, $locale); // confirm method name
} catch (\Throwable $e) {
report($e);
return null;
}
return $raw ? ProductContent::fromIcecat($raw) : null;
}
}
This is the single highest-value decision you make with this package: it gives you a fake for tests, a seam to add caching, and an escape hatch if the package dies.
Cache hard, including misses. Icecat product data is near-static — copy, images, and specs change on the order of months. Cache hits forever keyed on gtin:locale; cache misses (product genuinely not in the catalog) for a day or a week so you don't burn quota re-asking for the same unknown EAN across a 40k-row import.
return Cache::rememberForever("icecat:{$gtin}:{$locale}", function () use ($gtin, $locale) {
return $this->provider->findByGtin($gtin, $locale);
});
Always queue. Icecat responses take one to three seconds, occasionally much longer. A synchronous lookup in a web request will time out under any real load. Use a job with rate limiting and retries:
final class EnrichProductFromIcecat implements ShouldQueue
{
public $tries = 3;
public $backoff = [10, 60, 300];
public $middleware = [new RateLimited('icecat')];
public function __construct(public int $productId) {}
public function handle(IcecatContentProvider $provider): void
{
$product = Product::findOrFail($this->productId);
$content = $provider->findByGtin($product->ean);
if ($content === null) {
$product->update(['icecat_status' => 'not_found', 'icecat_checked_at' => now()]);
return;
}
$product->update([
'name' => $product->name ?: $content->title,
'brand' => $content->brand,
'description' => $content->description,
'icecat_id' => $content->icecatId,
'icecat_status' => 'synced',
'icecat_checked_at' => now(),
'icecat_raw' => $content->raw, // json column, invaluable for replay
]);
$product->specs()->upsert($content->specs, ['product_id', 'key'], ['value', 'group']);
}
}
Define RateLimited::for('icecat', ...) in AppServiceProvider and set it to whatever your account quota allows — start conservative at 30–60/minute.
Fan out with batches. For a catalog backfill, Bus::batch(Product::whereNull('icecat_checked_at')->pluck('id')->map(fn ($id) => new EnrichProductFromIcecat($id)))->dispatch(). The batch gives you progress in Horizon and a then() hook for a summary report.
Store the raw payload. A json column costs you nothing and lets you re-map with a local artisan command when you discover Icecat exposes a spec group you weren't capturing, without re-hitting the API.
Fallback chain on lookup. GTIN first; if that returns nothing, try brand + manufacturer part number (many older or private-label SKUs are only indexed that way); then give up and mark the row. Log the tier that succeeded — it tells you how dirty your EAN data is.
Add an admin action. A "Re-fetch from Icecat" button in Filament/Nova that dispatches the same job is the feature your merchandisers will actually use daily, more than the import.
Testing. If the package uses Guzzle under the hood, inject a Client with a MockHandler in tests, or simply bind a fake ProductContentProvider — the latter is why the wrapper exists. Note that Http::fake() will not intercept anything if the package uses raw curl or its own Guzzle client; check src/ before assuming.
EN gives you the English record; specs and descriptions genuinely differ per locale. If you're multi-market, cache and store per-locale rather than syncing once in English and calling it done.Http:: isn't there. Wrap it yourself rather than editing vendor/.composer.json ("haringsrob/icecat": "^1.0"), keep the wrapper interface, and accept that you may fork. The good news: the actual work is one POST to Icecat's API — reimplementing it with Http::asForm()->post() is an afternoon, and that is your real insurance policy.icecat_raw so you can replay mapping logic offline with an artisan command instead of re-requesting.How can I help you explore Laravel packages today?