jakiboy/apaapi
APAAPI is a PHP wrapper for Amazon Product Advertising API v5, offering fluent methods for product searches, item lookups, browse nodes, and response handling. It simplifies credentials, request signing, and endpoint configuration for Laravel and standalone apps.
1. Install the library plus a PSR-18 stack. apaapi is HTTP-client agnostic; it speaks PSR-18/PSR-17, so you must bring your own implementation. Guzzle is the path of least resistance in Laravel (it's usually already installed):
composer require jakiboy/apaapi guzzlehttp/guzzle
Without a PSR-18 client and PSR-17 request/stream factories bound, the first call fails with a "no HTTP client" style exception — that is the #1 first-run stumble.
2. Get credentials. You need an Amazon Associates/PA-API account with access_key, secret_key, and an approved partner_tag. Note the two auth paths the package advertises: classic PA-API 5.0 (SigV4) and the newer OAuth 2.0 / credential-less Creators API flow. Pick one deliberately — they are not interchangeable and the credential shape differs.
3. Put credentials in config, not code. Add to config/services.php:
'amazon' => [
'access_key' => env('AMAZON_ACCESS_KEY'),
'secret_key' => env('AMAZON_SECRET_KEY'),
'partner_tag' => env('AMAZON_PARTNER_TAG'),
'locale' => env('AMAZON_LOCALE', 'US'),
],
4. First use case: keyword search returning titles + prices.
use Apaapi\Config;
use Apaapi\Client;
use Apaapi\Query;
use Apaapi\Operations\SearchItems;
$config = Config::create()
->setCredentials([
'access_key' => config('services.amazon.access_key'),
'secret_key' => config('services.amazon.secret_key'),
'partner_tag' => config('services.amazon.partner_tag'),
])
->setLocalization(config('services.amazon.locale'))
->setHttpClient(new \GuzzleHttp\Client())
->setRequestFactory(new \GuzzleHttp\Psr7\HttpFactory())
->setStreamFactory(new \GuzzleHttp\Psr7\HttpFactory());
$operation = new SearchItems();
$query = (new Query())
->setKeywords('laravel book')
->setItemCount(10)
->setResources(['ItemInfo.Title', 'Images.Primary.Medium', 'Offers.Listings.Price']);
$operation->setQuery($query);
$response = Client::create($config)->execute($operation);
$data = json_decode((string) $response->getBody(), true);
Where to look first: the src/ tree — Config, Client, Operations/* (one class per PA-API operation), Query, Localization, Response, Exception. Then examples/ in the repo and the README's usage section. Operation classes are the map of everything the API can do; read the constructor/setters of Query to see which parameters are supported before hand-rolling arrays.
Note on naming: this library is small and its v2 API surface has churned. Treat the method names above as the shape of the API and confirm exact signatures against the README/tag you install — wrap it in your own service class (below) so a bump doesn't leak into 40 call sites.
Bind once in a service provider. Never construct Config in a controller. A singleton is correct for the config object, but see the localization gotcha below.
// app/Providers/AmazonServiceProvider.php
public function register(): void
{
$this->app->singleton(Client::class, function ($app) {
$http = new \GuzzleHttp\Client(['timeout' => 10, 'connect_timeout' => 5]);
$factory = new \GuzzleHttp\Psr7\HttpFactory();
return Client::create(
Config::create()
->setCredentials(config('services.amazon.credentials'))
->setLocalization(config('services.amazon.locale'))
->setHttpClient($http)
->setRequestFactory($factory)
->setStreamFactory($factory)
->setLogger($app['log'])
);
});
}
Wrap it in one application-level service. This is the single most valuable pattern. Your app should depend on AmazonCatalog::search() / ->items(), not on Apaapi\*:
class AmazonCatalog
{
public function __construct(private Client $client) {}
public function search(string $keywords, array $resources = self::DEFAULT): array
{
$op = new SearchItems();
$op->setQuery((new Query())->setKeywords($keywords)->setItemCount(10)->setResources($resources));
return $this->decode($this->client->execute($op));
}
}
Benefits: cache keys, retries, logging, and response normalization all live in one place, and you can fake AmazonCatalog in feature tests without touching HTTP at all.
Batch with GetItems, don't loop. GetItems accepts up to 10 ASINs per request. A product-listing page with 40 ASINs = 4 calls, not 40. Combined with the TPS ceiling (below), this is the difference between a page load and a timeout.
Request the minimum Resources you need. The default response is thin; asking for ItemInfo.* and Offers.* on ten items produces hundreds of KB and slows every call. Declare a small constant set per screen (listing, detail, price-check) and reuse it.
Multi-marketplace: clone, don't mutate. If you support .fr and .de in one app, build a fresh Config/Client per locale rather than calling setLocalization() on the shared singleton. A mutated singleton silently returns French prices on a German page — and only under some request orderings, which makes it brutal to reproduce.
Cache aggressively, but lawfully. PA-API requires you to refresh pricing data on a schedule (roughly 24h max for price/availability) and forbids indefinite archiving of most offer data. A pragmatic layer:
return Cache::remember("amazon:item:{$asin}:{$locale}", now()->addHours(12), fn () => ...);
Store the fetch timestamp alongside the payload so you can render "price as of …", which the Associates operating agreement expects.
Respect the throughput limit in the queue, not the request. PA-API grants ~1 request/second with a small burst. A ThrottleRequests-style Redis lock around each job, or RateLimited job middleware with releaseAfter, keeps you off 429s when a bulk import fans out.
public function middleware(): array
{
return [(new RateLimited('amazon-api'))->releaseAfter(1)];
}
Normalize the response into DTOs immediately. PA-API JSON is deeply nested (Items[0].ItemInfo.Title.DisplayValue) and shape-shifts when fields are missing. Map to AmazonItem value objects (asin, title, imageUrl, price, currency, url) at the boundary; every view and JSON resource downstream becomes trivial and testable.
Testing: Http::fake() is useless here — this isn't the Laravel HTTP client. Fake your AmazonCatalog wrapper for feature tests, and for unit tests inject a stub PSR-18 ClientInterface that returns canned JSON. Keep one recorded real response per operation as a fixture; that's your regression net.
config/apaapi.php, no facade. Everything above is yours to write. Budget for it.guzzlehttp/guzzle is usually enough (it ships PSR-7), but if you use Symfony HttpClient or a bare PSR-18 adapter you also need nyholm/psr7 or similar, and you must pass the same object as both request and stream factory.webservices.amazon.fr / eu-west-1). Let the library's Localization drive the endpoint; don't hand-set hosts.getBody() doesn't throw on Errors — you must inspect $data['Errors'] ?? []. 429 means throttle; 400 InvalidParameterValue usually means a bad ASIN format or a resource name that doesn't exist for that operation.(string) $response->getBody() or you'll get a resource handle in your logs. Don't call getContents() twice on the same response.ItemCount is capped at 10 and GetItems at 10 ASINs; anything above is silently clamped or rejected. Paginate with ItemPage rather than inflating counts.php artisan config:cache freezes env() calls: config must be read via config(), never env() inside the provider closure.SearchItems accepts Keywords broadly but GetItems is ASIN-only. Validate/normalize before dispatching to avoid burning your TPS on guaranteed 400s.Response object exposing status, headers, and the built URI/request body — invaluable when Amazon rejects a request and you need to reproduce it with curl.rel="nofollow sponsored", image usage rules, and price-freshness requirements are all things a reviewer will check. Encode them in your Blade component/DTO so no developer can forget.composer update. Your service wrapper is what makes that upgrade a one-file change.How can I help you explore Laravel packages today?