thewirecutter/paapi5-php-sdk
A PHP SDK for Amazon's Product Advertising API 5.0, offering generated client classes for product search, item lookup, and browse nodes with request signing and typed responses. Useful for Laravel/PHP apps integrating Amazon Associates product data.
Install
composer require thewirecutter/paapi5-php-sdk
The fork keeps Amazon's generated namespace layout, so all classes live under Amazon\ProductAdvertisingAPI\v1\.... Confirm the installed version's namespace if you have trouble autoloading — forks occasionally reshuffle the PSR-4 map.
What you need before writing a line of code
yourtag-20.| Marketplace | Host | Region |
|---|---|---|
| US | webservices.amazon.com |
us-east-1 |
| CA | webservices.amazon.ca |
us-east-1 |
| UK | webservices.amazon.co.uk |
eu-west-1 |
| DE/FR/IT/ES | webservices.amazon.{de,fr,it,es} |
eu-west-1 |
| JP | webservices.amazon.co.jp |
us-west-2 |
| AU | webservices.amazon.com.au |
us-west-2 |
| IN | webservices.amazon.in |
eu-west-1 |
First working request
use Amazon\ProductAdvertisingAPI\v1\Configuration;
use Amazon\ProductAdvertisingAPI\v1\Api\DefaultApi;
use Amazon\ProductAdvertisingAPI\v1\com\amazon\paapi5\v1\SearchItemsRequest;
use Amazon\ProductAdvertisingAPI\v1\com\amazon\paapi5\v1\SearchIndex;
use Amazon\ProductAdvertisingAPI\v1\com\amazon\paapi5\v1\SearchItemsResource;
$config = new Configuration();
$config->setAccessKey(config('paapi.access_key'));
$config->setSecretKey(config('paapi.secret_key'));
$config->setPartnerTag(config('paapi.partner_tag'));
$config->setRegion('us-east-1');
$config->setHost('webservices.amazon.com');
$api = new DefaultApi(new \GuzzleHttp\Client(['timeout' => 10]), $config);
$request = (new SearchItemsRequest())
->setPartnerTag(config('paapi.partner_tag'))
->setKeywords('standing desk')
->setSearchIndex(SearchIndex::ALL)
->setItemCount(10)
->setResources([
SearchItemsResource::ITEM_INFOTITLE,
SearchItemsResource::IMAGESPRIMARYMEDIUM,
SearchItemsResource::OFFERSLISTINGSPRICE,
]);
$response = $api->searchItems($request);
First use case to build: a ProductLookup service that takes an ASIN and returns title / image / price as a plain array. Don't call the SDK from controllers — wrap it immediately so you can swap caching, retries, and error handling later.
Order of exploration: Configuration (credentials + host) → DefaultApi (the only class you call) → the *Request classes (immutable-ish fluent setters) → the *Response classes (heavily nested, mostly null until you request the resource).
1. One configured API instance per marketplace, built from config
The Configuration object is mutable and cheap — build a factory rather than a singleton so multi-marketplace sites (US + UK) can coexist:
final class PaapiFactory
{
public static function make(string $marketplace): DefaultApi
{
$m = config("paapi.markets.{$marketplace}");
$config = new Configuration();
$config->setAccessKey($m['access_key']);
$config->setSecretKey($m['secret_key']);
$config->setPartnerTag($m['partner_tag']);
$config->setRegion($m['region']);
$config->setHost($m['host']);
$config->setDebug(false);
return new DefaultApi(
new Client(['timeout' => 8, 'connect_timeout' => 3]),
$config
);
}
}
2. Always enumerate setResources() explicitly
PA-API 5.0 returns nothing you didn't ask for. This is the single biggest source of "the SDK is broken" tickets — the response object deserializes fine but every property is null.
Group resources into a constant per use case so call sites stay readable:
const LISTING_RESOURCES = [
SearchItemsResource::ITEM_INFOTITLE,
SearchItemsResource::ITEM_INFOFEATURES,
SearchItemsResource::IMAGESPRIMARYLARGE,
SearchItemsResource::OFFERSLISTINGSPRICE,
SearchItemsResource::OFFERSLISTINGSMERCHANTINFO,
SearchItemsResource::BROWSENODEINFOBROWSENODES,
];
3. Chunk GetItems — hard limit of 10 ASINs
$results = collect($asins)
->chunk(10)
->flatMap(function ($chunk) use ($api) {
$req = (new GetItemsRequest())
->setPartnerTag($this->partnerTag)
->setItemIds($chunk->values()->all())
->setResources(self::LISTING_RESOURCES)
->setCondition('Any');
try {
return $api->getItems($req)->getItemsResult()->getItems() ?? [];
} catch (ApiException $e) {
report($e);
return [];
}
});
4. Normalize to your own DTOs immediately
The SDK response tree is five or six levels deep (getItemsResult()->getItems()[0]->getItemInfo()->getTitle()->getDisplayValue()). Map once into a Product value object so the rest of the app never imports an Amazon\... class. This also lets you write tests without the SDK.
private function mapItem($item): ?Product
{
$title = $item->getItemInfo()?->getTitle()?->getDisplayValue();
if (!$title) return null; // partially-accessible item
return new Product(
asin: $item->getASIN(),
title: $title,
price: $item->getOffers()?->getListings()[0]?->getPrice()?->getAmount(),
imageUrl: $item->getImages()?->getPrimary()?->getLarge()?->getURL(),
);
}
5. Cache aggressively — your quota is the real bottleneck
PA-API gives you roughly 1 request/second and a daily cap tied to your affiliate revenue. Every SearchItems call should go through a cache keyed on marketplace + operation + normalized request payload:
$key = 'paapi:'.md5($marketplace.serialize($request->__toString() ?: $request));
return Cache::remember($key, now()->addHours(6), fn () => $api->searchItems($request));
Cache the response object, not just the mapped array — then changing your mapping layer doesn't invalidate the cache. Serialize with json_encode($response) and json_decode(..., true) into arrays, or store the raw toArray() output; regenerating typed objects from arrays isn't worth it for read paths.
6. Retry decorator for 429 / 503
The SDK throws ApiException for both throttling and genuine errors. Distinguish by status code and back off with jitter — never retry blindly in a tight loop, you'll dig the hole deeper.
private function withRetry(callable $fn, int $attempts = 3)
{
foreach (range(1, $attempts) as $i) {
try {
return $fn();
} catch (ApiException $e) {
$code = $e->getCode();
if (!in_array($code, [429, 503]) || $i === $attempts) throw $e;
usleep((2 ** $i) * 250_000 + random_int(0, 100_000));
}
}
}
7. Push bulk work to the queue
Any "sync 5,000 ASINs nightly" job must be queued with a Redis::throttle('paapi')->allow(1)->every(1) guard or a dedicated single-worker queue. Doing it inside a web request will time out and burn quota.
8. Handle Errors payloads that arrive alongside results
GetItems can succeed (200) while individual ASINs are listed in ItemsResult->Errors (e.g. ItemNotAccessible, InvalidParameterValue). Check both the top-level result and the per-item error list; don't assume a non-throwing call means every ASIN resolved.
9. Testing
Stub DefaultApi in the container or inject it behind your own interface. The generated classes are final-ish and constructor-heavy, so mocking the SDK directly is painful — mocking your ProductRepository is trivial.
AccessDenied errors, not a helpful message. Double-check the table above. us-east-1 is correct for CA and BR despite them being separate marketplaces — the region is an AWS region, not a country.setResources() call, not an auth problem. Verify with a single known-good ASIN and an explicit ItemInfo.Title before debugging credentials.setPartnerTag() must be called on the request too, not just on Configuration. Recent versions of the generated request classes default it from config in some code paths and not others — set it explicitly every time. Mismatched tags surface as InvalidPartnerTag / AssociateValidationFailure.ApiException::getResponseBody() is where the real error lives. The exception message is generic ("[400] Client error"). Log the decoded body — it contains __type, Message, and a RequestId that Amazon support will ask for.catch (ApiException $e) {
Log::warning('paapi', [
'status' => $e->getCode(),
'body' => json_decode($e->getResponseBody(), true),
'request' => $request,
]);
}
TotalResultCount and ItemCount are lies. They're estimates and are frequently far larger than what you can actually page to. PA-API caps SearchItems at 10 items per page and 10 pages of pagination (page 1–10), so a "full catalog scrape" is impossible by design.setItemPage() (1-indexed), not tokens. If you need more than 100 results you must vary the query (keywords, BrowseNodeId, price ranges) and dedupe.NoResults is a 200 with an empty SearchResult, while NoResults on GetItems for a single ASIN returns a per-item error. Handle both shapes.config/services.php fed from env, and add them to your .env.example as blanks. Also add a startup check that fails loudly in production if any of the four values is empty — silent misconfig is hard to notice because the SDK will happily sign with a blank key and return a 401.Configuration::setDebug(true) can dump headers, including your Authorization header. If you need request tracing, log only the RequestId from the response or exception.timeout and connect_timeout when constructing the client.SearchItems response as JSON is often 50–200 KB. If you cache to Redis, compress (gzencode) or cache only the mapped arrays; if you cache to the filesystem, be aware of inode pressure on high-cardinality query sets.ItemIds must be ASINs, not URLs or SKUs, and are case-sensitive — uppercase them before calling or you'll get InvalidParameterValue for valid products.BrowseNodeInfo lookups are a separate rate-limited resource. GetBrowseNodes counts against the same quota as SearchItems; cache browse node trees for days, not hours — they barely change.PaapiClient (raw SDK calls, retries, logging), ProductRepository (mapping + caching), and controllers on top. That's the layer that lets you add a second marketplace, swap in Rainforest/Keepa as a fallback, or migrate off PA-API without touching business logic.How can I help you explore Laravel packages today?