owenvoke/opensea
A Laravel and PHP client for the OpenSea API, offering an expressive SDK for NFT marketplace data such as collections, assets, events, and wallets. It integrates easily into Laravel apps and standalone PHP projects.
Install and authenticate first.
composer require owenvoke/opensea
OpenSea's v2 API requires a key from https://opensea.io/settings/developer. Free tier is roughly 4 requests/second — that number should shape everything you build on top of this package.
If you're on Laravel, publish the config and put the key in .env rather than hardcoding it:
php artisan vendor:publish --tag="opensea-config"
OPENSEA_API_KEY=your-key-here
Where to look first. Don't start in the README — start in src/. Read in this order:
src/OpenSea.php (or equivalent connector class) — this is the Saloon connector; it defines the base URL, the X-API-KEY header, and which resource methods are exposed.src/Resources/ — one class per API area (Collection, Nft, Event, Order, …). Each public method maps 1:1 to an endpoint, which is the fastest way to discover what's actually supported versus what OpenSea documents.src/Dto/ / src/Responses/ — the typed response objects. Their nullable properties tell you exactly what the API can omit in practice.First use case: collection stats for a dashboard.
use OwenVoke\OpenSea\OpenSea;
$openSea = app(OpenSea::class); // or OpenSea::make()
$stats = $openSea->collection()->stats('boredapeyachtclub');
$stats->floorPrice; // wei, as a string
$stats->volume24h;
$stats->numOwners;
That single call is the 80% case: a floor-price ticker, a "is this collection moving" alert, or a portfolio valuation. Get that working end-to-end (key → request → DTO → rendered number) before touching anything else, because it exercises the auth header, the connector, the DTO hydration, and the wei conversion all at once.
Wrap the connector, never call it from controllers. The package hands you a stateless connector; your app wants a domain service with caching, retries, and a stable return type.
final class CollectionStatsService
{
public function __construct(private OpenSea $openSea) {}
public function floorPriceEth(string $slug): ?string
{
return Cache::remember("os:stats:{$slug}", now()->addMinutes(5), function () use ($slug) {
$stats = $this->openSea->collection()->stats($slug);
return $stats->floorPrice
? bcdiv((string) $stats->floorPrice, '1e18', 6)
: null;
});
}
}
Cache aggressively, and know your staleness budget. Floor prices are the most-requested and least time-sensitive thing you'll fetch. Five minutes for a dashboard, sixty seconds for a trading tool, thirty seconds only if you're paying for a higher tier. Caching is also your primary rate-limit defence.
Batch wallet lookups with a pool, not a foreach. Portfolio valuation means N collections or N wallets. Saloon's pool support (or Laravel's Http::pool if you bypass the connector) lets you fan out; a naive loop against a 4 req/s limit will make an endpoint time out at ~20 wallets.
$responses = $pool->send(); // keep concurrency at 3-4 on the free tier
Push anything larger into a queued job with ->onQueue('opensea') and a dedicated worker, so a slow API never blocks a web request.
Poll events with cursors, not timestamps. The events endpoints return a next cursor; persist it (a sync_state table or cache key) and pass it back on the next run. Timestamp-based polling silently drops events that occur during pagination gaps.
$cursor = Cache::get("os:cursor:{$slug}");
$events = $openSea->event()->byCollection($slug, cursor: $cursor);
Cache::forever("os:cursor:{$slug}", $events->next);
Prefer the DTOs but keep raw JSON reachable. Use typed accessors for the fields you rely on, and fall back to the underlying array for anything OpenSea adds mid-version. If the package's DTOs are strict, a new/renamed API field can throw during hydration — a wrapper that catches and logs the raw payload will save you an incident.
Mock in tests, always. Saloon ships a mock client; use it so CI never touches the live API (and never burns your key's quota).
$openSea = OpenSea::fake([
'*/collections/*/stats' => Http::response(['total' => ['floor_price' => '1000000000000000000']], 200),
]);
Integration surface. If you also produce NFTs or place orders, note that order creation on OpenSea requires EIP-712 signing with the seller's wallet — that's a signing-library job (web3p/web3.php, ethers) that this package isn't going to do for you. Treat the package as a read layer and handle writes separately.
Every wei value is a string, and that's on purpose. floor_price regularly exceeds PHP_INT_MAX on 32-bit builds and floats will silently lose precision on large values. Use bcdiv/brick/math, never (int) or (float), and never do arithmetic in wei then cast at the end.
floor_price is nullable and can be zero. A collection with no listings returns no floor, not a floor of zero. Distinguish null (no data) from "0" (genuinely worthless) before you render it — dashboards that show "Ξ0.00 floor" for a delisted collection erode trust fast.
429s are normal, not exceptional. Add Saloon's retry middleware with exponential backoff and respect Retry-After if present. Without it, a single burst of 20 wallet lookups will start failing and your error logs will blame the wrong thing.
Slugs vs. contract addresses are different keys. Collections are addressed by slug (boredapeyachtclub); individual NFTs by (chain, contract, tokenId). Passing a contract address where a slug is expected returns a 400 that reads like a 404. Also: chain is a required string (ethereum, matic, base, arbitrum, …) on most asset endpoints — omit it and the error is unhelpful.
Pagination is cursor-based. next is an opaque token, not an offset. Loop until it's empty and persist it between runs; don't try to compute pages.
Pin the version. Low star count and low dependent count mean the package tracks OpenSea's API at one person's pace. "owenvoke/opensea": "2.*" in composer.json, and check the changelog before minor bumps — endpoint deprecations land here as breaking changes.
Watch the API version boundary. OpenSea retired large parts of v1; if you find a resource method documented but returning 404/410, you're probably looking at a leftover v1 endpoint. Cross-check the path against OpenSea's current v2 reference rather than trusting the wrapper's method name.
Debugging tip: drop a Saloon middleware that logs the full request URL and response body to a dedicated channel. When a DTO fails to hydrate, the exception message rarely tells you which field was unexpected — the raw JSON does, immediately.
Extension points worth knowing: the connector is subclassable, so you can override the base URL to point at a local fixture server or a caching proxy; and Saloon middleware is the right place for cross-cutting concerns (rate limiting, retries, request logging, key rotation) rather than sprinkling them through your service classes.
How can I help you explore Laravel packages today?