victorybiz/laravel-crypto-payment-gateway
Add cryptocurrency checkout to Laravel apps with this configurable payment gateway. It supports multiple coins, payment address generation, confirmation tracking, and callback or webhook notifications, making it easy to accept crypto orders alongside traditional payments.
Start by getting a GoUrl.io merchant account and grabbing both keys — the public key is safe to embed in the browser box, but the private key is only used for server-side IPN verification and must never leak into HTML or JS.
Install and publish
composer require victorybiz/laravel-crypto-payment-gateway
php artisan vendor:publish --provider="Victorybiz\LaravelCryptoPaymentGateway\LaravelCryptoPaymentGatewayServiceProvider"
php artisan migrate
The package publishes a config file (typically config/crypto-payment-gateway.php) and a migration that creates a crypto_payments table.
Configure the environment
CRYPTO_PAYMENT_GATEWAY_PUBLIC_KEY=pk_xxx
CRYPTO_PAYMENT_GATEWAY_PRIVATE_KEY=sk_xxx
Look at these first, in order:
config/crypto-payment-gateway.php — keys, box style, allowed coin types, callback URL./crypto-payment/callback).crypto_payments migration — this is your source of truth for the fields the package writes on each IPN (order id, status, txid, amount, address, confirmed timestamp).First use case — render a payment box on a checkout page. The façade exposes a getPaymentBox($amount, $currency, $options = []) helper that returns HTML (a form/iframe plus the GoUrl script). You must output it raw:
{!! \Victorybiz\LaravelCryptoPaymentGateway\LaravelCryptoPaymentGateway::getPaymentBox(
0.0015, 'LTC', [
'callbackUrl' => route('crypto.callback'),
'redirectUrl' => route('orders.show', $order),
'orderId' => $order->id,
'userID' => auth()->id(),
])
!!}
The user gets a coin picker (or a fixed coin, depending on the options you pass), pays, GoUrl fires an IPN to callbackUrl, and a row in crypto_payments is created/updated. From that point your order state machine should key off crypto_payments.status.
Order-first, then box. Create an application-level Order/invoice record before rendering the box, with a stable orderId. GoUrl and the package use orderId to correlate the IPN back to your record, and reusing one will make reconciliation ambiguous. Pass that ID into getPaymentBox and store it in your own orders table alongside user and amount.
Listen on the table, not on the request. The cleanest integration point is to treat crypto_payments as an event log:
everyMinute) queries rows where status = 'confirmed' and fulfilled_at IS NULL, then marks the order paid and sets fulfilled_at.CryptoPaymentUpdated) and do the same. Either way, fulfillment should be idempotent — check fulfilled_at / your own paid_at column inside a transaction before granting access or credit.Map GoUrl statuses explicitly. GoUrl emits statuses such as waiting, confirming, confirmed, cancelled/expired. Treat only confirmed (or the package's equivalent constant) as paid. Anything else is "don't ship yet." Build a small enum/map in your app so you never string-compare in Blades.
Two common rendering patterns.
Attach payments to users. Add a hasMany(CryptoPayment::class) to your User model and pass userID in the box options. This makes dashboards, support lookups, and "my payments" screens trivial and avoids storing raw addresses on the user row.
Callback as the source of truth (not the redirect). The redirectUrl is where the user's browser goes after paying; it is not proof of payment. Anything showing "Thanks, you paid" should be driven by a check against crypto_payments for that order — otherwise a user can spoof the success page by simply hitting the redirect URL.
Keep the box rendering in one Blade partial. Wrap getPaymentBox() in a component (<x-crypto-payment-box :order="$order" />) so you centralize the option array, the orderId, and any CSP nonce handling. This is also where you'd swap in a "test mode" coin if you want a sandbox flow.
Custom callback route. If you don't want the package's default route (or need to add auth/logging), set callbackUrl to your own controller and forward the request through the package's handler, or let the package handle it and just observe the DB — the DB-observer pattern is less invasive and easier to test.
Testing. Fake the payment by inserting a crypto_payments row directly in tests (status = 'confirmed'), then assert your fulfillment job picks it up. Use Http::fake() if your own code posts to GoUrl; use Bus::fake() to assert the fulfillment job dispatches.
Localhost will never work. GoUrl's IPN is a server-to-server POST from their infrastructure, so it can only reach a publicly reachable URL. Use ngrok/expose/Laravel Valet share or a staging server. If your callback URL is http://127.0.0.1, the box will render but the payment will sit in waiting forever and you'll spend an afternoon debugging nothing.
Exclude the callback from CSRF and auth middleware. IPNs arrive as a POST with no session and no CSRF token. If the package's route is under web middleware, add the callback path to VerifyCsrfToken::$except and make sure nothing in your route group requires auth. Verify in php artisan route:list which middleware stack the callback actually has.
Use strings, not floats, for crypto amounts. BTC/LTC amounts have 8 decimals; PHP floats will round and can throw off amount-match validation. Store amounts as DECIMAL(18,8) or strings, and compare with a tolerance (e.g. ±1 satoshi) when reconciling.
Unique order_id. GoUrl dedupes on the order ID you pass. If a customer abandons a box and you re-render with the same orderId, you may get stale state or a rejected second IPN. Either reuse the same payment row intentionally (recommended) or generate a new order ID and void the old row.
Only the public key belongs in the rendered box. If you ever see the private key in a page source, config dump, or error report, rotate it immediately in the GoUrl dashboard — the private key is what authenticates the IPN, so a leak means an attacker can forge "confirmed" callbacks.
Config cache after env changes. php artisan config:cache will freeze CRYPTO_PAYMENT_GATEWAY_* values; if you add keys to .env and the box still fails, clear the cached config. Same story for queue workers — restart them.
Status constant drift. The exact status strings the package writes are defined by the upstream GoUrl cryptobox script, not by Laravel. Inspect a real row in crypto_payments in staging before writing your fulfillment logic — don't trust a blog post or a README example from an older release.
The box is client-side JS. It injects a GoUrl script and (depending on config) an iframe. Adblockers, strict Content-Security-Policy (script-src/frame-src), and consent managers regularly break it. If the box renders blank in production but works in staging, check CSP, adblock, and whether the public key is being served over HTTPS. Give the box a visible fallback (raw address + QR) so users on hostile networks aren't dead-ended.
Logging for support. GoUrl's dashboard has an IPN log per transaction; correlate that with storage/logs/laravel.log and your own crypto_payments.order_id. When a "customer says they paid" ticket arrives, those three sources resolve 95% of cases in minutes.
Extension points when the package falls short. If you need richer events, multi-currency accounting, or custom retry logic, the lowest-friction approach is to not fork the package: keep it as the box renderer + IPN writer, and put all business logic in your own listener/job against crypto_payments. That keeps you upgrade-safe when a new release ships (last release was 2024-04-01, so expect infrequent updates — pinning the version and owning the wrapper pays off).
How can I help you explore Laravel packages today?