A price widget in a Laravel app looks like a ten minute job: call an API, cache the result, print the number. The two places it goes wrong are the cache (you hammer the API on every page view) and the math (you cast a price to float and lose digits). This post wires up both properly, using the official PHP SDK for the Bitculator Data API.
1. Install the SDK
composer require bitculator/sdk
The SDK needs PHP 8.1+, ext-curl and ext-json, and has no other dependencies. Get an API key from your Bitculator developer page and put it in .env:
BITCULATOR_API_KEY=your-key-here
Keep the key server-side. It is a Bearer token and does not belong in JavaScript.
2. Register the client once
Add the key to config/services.php, so config:cache works:
'bitculator' => [
'key' => env('BITCULATOR_API_KEY'),
],
Then bind one client in AppServiceProvider::register():
use Bitculator\Bitculator;
$this->app->singleton(
Bitculator::class,
fn () => new Bitculator(config('services.bitculator.key')),
);
Anything in your app can now type-hint Bitculator and get the same configured instance. The constructor also takes timeout and maxRetries (the SDK retries 429, 5xx and network errors twice by default).
3. Put the API behind a small service and a cache
Do not call the SDK from controllers or Blade. One small class keeps the API call, the cache and the error handling in one place:
namespace App\Services;
use Bitculator\Bitculator;
use Bitculator\Exceptions\RateLimitException;
use Illuminate\Support\Facades\Cache;
class CoinPrices
{
public function __construct(private Bitculator $client) {}
public function price(string $slug): string
{
try {
return Cache::remember(
"bitculator:price:{$slug}",
60,
fn () => $this->client->coins->get($slug)['price'],
);
} catch (RateLimitException $e) {
abort(503, 'Prices are busy, try again shortly.', [
'Retry-After' => (string) ($e->retryAfter ?? 30),
]);
}
}
}
Two details matter here. Cache::remember means a page that gets 10,000 views a minute makes one API call a minute per coin, not 10,000. And the method returns a string, which is deliberate.
4. Prices are strings, keep them strings
The API returns prices as decimal strings, for example "63520.780763913". That is on purpose: a JSON number is parsed into a float by most clients, and a float cannot hold every decimal digit of a price exactly. If you cast it, you silently change the value.
So do the math with BCMath (ext-bcmath), which works on strings:
$price = app(CoinPrices::class)->price('bitcoin'); // "63520.780763913"
$holding = '0.25';
$value = bcmul($holding, $price, 8);
bcmul takes the scale as its third argument, so you decide how many decimals to keep. Only format the number for display at the last moment, in the Blade view or a resource, and never feed the formatted string back into more math.
5. List the top coins
The SDK returns a Page. Iterating it walks every page for you, so for a top-50 table you ask for one page and read data:
$page = $client->coins->list(['per_page' => 50, 'sort' => '-marketcap']);
foreach ($page->data as $coin) {
echo $coin['symbol'], ' ', $coin['price'], "\n";
}
Cache this the same way as the single price, with the key including the per_page and sort values.
6. Know your quota
After every call the client exposes the rate-limit headers:
$quota = $client->quota(); // limit, remaining, reset
Log remaining somewhere you will see it, so a traffic spike shows up on a graph before it shows up as 429s.
Wrap-up
The pattern is: one singleton client, one service, a short cache, strings in and BCMath for math. The same API also has official SDKs for TypeScript, Python, Go, Java, .NET, Rust and C++, so the pattern carries over if part of your stack is not PHP. Full endpoint list is in the API documentation, and the SDK source is on GitHub. If you want the reasoning behind decimal strings in more depth, I wrote about it in Why a crypto price API should send decimal strings, not numbers.
Top comments (0)