shakurov/coinbase
Laravel package for integrating Coinbase Commerce cryptocurrency payments. It wraps the Coinbase Commerce API to create charges and checkouts, manage payment workflows, and handle webhook events, with configurable credentials and a simple facade-based setup for Laravel applications.
The package is a thin Laravel service-provider/facade wrapper around Coinbase's official coinbase/coinbase-commerce PHP SDK. Treat it as configuration glue plus a facade — not as a full checkout solution.
Install and configure
composer require shakurov/coinbase
php artisan vendor:publish --provider="Shakurov\Coinbase\CoinbaseServiceProvider"
That publishes config/coinbase.php, which is essentially just your API key (and sometimes an API base URL). Add to .env:
COINBASE_API_KEY=your_commerce_api_key
Where to look first
config/coinbase.php — confirm the env key name and any base-URL override.Coinbase) in the provider — this tells you exactly which SDK classes are proxied.First use case: sell one fixed-price item
use Shakurov\Coinbase\Facades\Coinbase;
$charge = Coinbase::createCharge([
'name' => 'Pro plan (1 year)',
'description' => 'Annual subscription',
'local_price' => ['amount' => '99.00', 'currency' => 'USD'],
'pricing_type' => 'fixed_price',
'metadata' => ['order_id' => $order->id],
'redirect_url' => route('checkout.thanks'),
'cancel_url' => route('checkout.cancelled'),
]);
$order->update(['coinbase_charge_code' => $charge['code']]);
return redirect($charge['hosted_url']);
That's the loop: create charge → persist code → redirect to hosted_url → reconcile via webhook.
Persist the charge, then redirect. Store code, hosted_url, the requested amount, and the currency on your orders table before redirecting. You will need them for reconciliation and for support tickets.
Metadata is your join key, but never your source of truth. Put order_id (and a UUID if you like) in metadata. On webhook, look up the order by metadata, then re-fetch the charge from the API and check payments[].status / timeline before marking paid. Metadata is attacker-controllable in the sense that it's echoed back, not signed in a way you should trust for amounts.
Webhook-driven state machine. Coinbase Commerce emits charge:created, charge:pending, charge:confirmed, charge:failed, charge:delayed, charge:resolved, and checkout equivalents. Recommended mapping:
| Event | Action |
|---|---|
charge:pending |
mark awaiting_payment |
charge:confirmed |
mark paid_pending_settlement (do not ship goods yet on some flows) |
charge:resolved |
mark paid, fulfill |
charge:failed |
mark failed, release inventory |
charge:delayed |
mark underpaid/overpaid, notify ops |
Handle underpaid and overpaid explicitly — they arrive as timeline statuses and are the #1 source of "customer says they paid" disputes.
Route and controller skeleton
Route::post('/webhooks/coinbase', CoinbaseWebhookController::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
public function __invoke(Request $request)
{
$signature = $request->header('X-CC-Webhook-Signature');
$payload = $request->getContent(); // raw body, NOT $request->all()
abort_unless($this->verify($payload, $signature), 400);
$event = json_decode($payload, true);
dispatch(new ProcessCoinbaseEvent($event))->onQueue('webhooks');
return response()->noContent();
}
Queue the work. Coinbase retries on non-2xx, and a slow handler will get you duplicate retries.
Idempotency. Deduplicate on the event id (and/or charge.code + event type) in a webhook_events table with a unique index. Retries and out-of-order delivery are normal; charge:confirmed can arrive after charge:resolved.
Checkouts vs charges. Use createCharge for one-off invoices and createCheckout (if the wrapper exposes it) when you want a reusable, multi-item hosted page. For most Laravel apps, charges plus metadata is simpler and easier to reconcile.
Testing. The package does not ship a fake. In tests, bind a mock of the underlying SDK client, or use Laravel's HTTP fake against api.commerce.coinbase.com. Assert on the request payload your service builds — that's where your bugs are, not in the wrapper.
Service layer. Wrap the facade in App\Services\CoinbaseGateway so the rest of your code depends on an interface. You will want to swap it (Coinbase Commerce has had deprecation chatter; the wrapper has 0 dependents and a 2023 last release).
$request->getContent() before any middleware mutates it. $request->all() re-serialized will not match.'amount' => '99.00', not 99.0. Floats will serialize with exponent or truncated decimals and the API will reject or misprice.\CoinbaseCommerce\Webhook::verifySignature($sig, $payload, $secret) directly — that's what the wrapper calls anyway.COINBASE_API_KEY, run php artisan config:clear. The SDK client is constructed once at container resolution; if you resolve it in AppServiceProvider::boot, you'll get stale config in queue workers.php artisan queue:restart) after any deploy that changes keys or the config file.redirect_url as proof of payment. Users hit the redirect URL whether or not they paid. Only the webhook (re-fetched from the API) should flip your order to paid.awaiting_payment with a timeout job rather than instantly failing it.charge:delayed is not charge:failed. Underpayment and overpayment both surface as delayed. If you auto-fail on delayed you will lose legitimate customers.app/Support/ if you need to. Pin the version in composer.json and don't rely on it for anything the SDK doesn't already do.\CoinbaseCommerce\ApiClient yourself and call ApiClient::init($key) in a service provider after theirs, or just bypass the facade.local_price currency must match your accounting. Don't assume USD — store the currency alongside every charge.Bottom line: a reasonable five-minute shortcut for a small Laravel app that just needs hosted crypto checkout and webhooks, provided you build your own webhook controller, idempotency table, and service layer around it. The package itself is not the risk — treating it as more than a facade over the SDK is.
How can I help you explore Laravel packages today?