Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Cashier Braintree Laravel Package

genealabs/laravel-cashier-braintree

Laravel Cashier Braintree brings Braintree payment handling to Laravel's Cashier API, offering subscriptions, one-off charges, and customer management through a familiar fluent interface.

View on GitHub
Deep Wiki
Context7

Getting Started

Preflight (do this before writing any feature code). The package is archived, last released 2019-10-09 against braintree/braintree_php 4.x. Do not add it as a floating ^4.0 requirement. Fork it into your own namespace, pin a commit SHA, and resolve it against your actual composer.json first — a dependency-resolution failure is the cheapest signal you'll get. Then run the package's own test suite in a throwaway branch before trusting anything in the README.

Install and wire-up. After the fork is vendored:

composer require genealabs/laravel-cashier-braintree:dev-main#<sha>
php artisan vendor:publish --tag=cashier-config
php artisan vendor:publish --tag=cashier-migrations
php artisan migrate

The migrations add braintree_id, paypal_email, card_brand, card_last_four, trial_ends_at to users plus subscriptions and subscription_items. If users already exists with data, review the published migration rather than blindly running it — add columns in a separate migration instead.

Set credentials in .env:

BRAINTREE_ENVIRONMENT=sandbox
BRAINTREE_MERCHANT_ID=
BRAINTREE_PUBLIC_KEY=
BRAINTREE_PRIVATE_KEY=

Add the trait to your billable model.

use Laravel\Cashier\Billable;

class User extends Authenticatable
{
    use Billable;
}

Where to look first. The 4.0.0 README is a frozen copy of Laravel Cashier's docs with Stripe swapped for Braintree — genuinely useful for learning the contract (newSubscription, swap, cancel, resume, charge, Billable), misleading wherever it implies current behavior. Read the source in this order: src/Billable.php (the entire public API lives here), src/Subscription.php (state transitions and proration), src/Http/Controllers/WebhookController.php (what actually gets reconciled), then tests/ to see which of those paths are actually covered — expect refunds and disputes to be thin.

First use case to build. One paid tier, one plan, card checkout via Braintree Drop-in, 14-day trial, monthly billing. Do not start with coupons, proration, or quantity. Get subscribe → webhook → entitlement flip → cancel working end-to-end in sandbox before adding a second plan.

// routes/web.php
Route::post('/subscribe', function (Request $request) {
    $request->user()
        ->newSubscription('main', 'monthly-plan-id')
        ->trialDays(14)
        ->create($request->payment_method_nonce);
});

Implementation Patterns

Client token → Drop-in UI → nonce → subscribe. Braintree nonces are single-use and short-lived, so the token must be minted per page load, not cached. Expose a small endpoint and render the Drop-in form against it:

Route::get('/braintree/token', function (Request $request) {
    return response()->json([
        'token' => $request->user()->braintree()->clientToken(),
    ]);
});
<div id="dropin-container"></div>
<button id="submit">Subscribe</button>
<script src="https://js.braintreegateway.com/web/dropin/1.x/js/dropin.min.js"></script>
<script>
braintree.dropin.create({
  authorization: '{{ $token }}',
  container: '#dropin-container'
}, (err, instance) => {
  document.getElementById('submit').onclick = () => {
    instance.requestPaymentMethod((err, payload) => {
      fetch('/subscribe', {
        method: 'POST',
        headers: {'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}'},
        body: JSON.stringify({payment_method_nonce: payload.nonce})
      });
    });
  };
});
</script>

Creating the customer. create() will create the Braintree customer on first call if braintree_id is null. If you need the customer to exist earlier (e.g. to attach a card before the first subscription), call $user->createAsBraintreeCustomer($nonce) explicitly and check the result — a failed customer creation surfaces as a Braintree\Exception from the SDK, which this package does not wrap uniformly.

Plan changes. swap() is the single entry point for upgrades, downgrades, and interval changes. Read the proration behavior in src/Subscription.php before shipping it — Braintree's proration primitives differ from Stripe's, and this package's swap does not expose every knob the upstream docs describe.

$user->subscription('main')->swap('annual-plan-id');
$user->subscription('main')->swapAndInvoice('annual-plan-id'); // if present in your pinned SHA

Lifecycle: cancel, resume, quantity.

$user->subscription('main')->cancel();      // at period end, remains active until then
$user->subscription('main')->resume();      // undo cancel-at-period-end
$user->subscription('main')->cancelNow();   // immediate
$user->subscription('main')->incrementQuantity();
$user->subscription('main')->updateQuantity(5);

Gate features on $user->subscribed('main') for the plan-level check and $user->subscription('main')->onTrial() / ->onGracePeriod() for the edges. Do not hand-roll ends_at comparisons against Carbon::now() in controllers — centralize it behind an active() check on your own model or a policy.

Webhooks are the source of truth, not the API response. The subscribe POST tells you a nonce was accepted; it does not tell you the subscription is active. Wire the webhook route immediately:

// routes/web.php — exempt from CSRF, see Gotchas
Route::post('braintree/webhook', 'BraintreeWebhookController@handleWebhook');
// app/Http/Controllers/BraintreeWebhookController.php
use Laravel\Cashier\Http\Controllers\WebhookController as CashierController;

class BraintreeWebhookController extends CashierController
{
    public function handleSubscriptionCanceled(array $payload)
    {
        // custom side effects: revoke seats, notify success team, etc.
        return parent::handleSubscriptionCanceled($payload);
    }
}

Subclassing is the intended extension point — the base controller maps subscription_canceled, subscription_charged_successfully, subscription_charged_unsuccessfully, subscription_expired, subscription_trial_ended to handlers you can override. Prefer overriding to forking the controller wholesale, so upstream fixes (if you make any) merge cleanly.

Reconcile, don't trust. Build a nightly job that pulls active subscriptions from the Braintree API and diffs against your subscriptions table. Recurring billing fails silently when a webhook is dropped; the diff is your only detection. This is your code, not the package's.

Testing. Braintree sandbox supports deterministic amounts and test nonces (e.g. fake-valid-nonce, fake-valid-visa-nonce). Use those in feature tests against the sandbox, but stub outbound calls in unit tests by mocking the gateway rather than the package internals. Verify at minimum: trial creation, swap without proration, cancel-at-period-end, cancelNow, resume, and a signed webhook payload replay.

Gotchas and Tips

It is archived — own that explicitly. No CVE triage, no Laravel major support, no Braintree SDK bump past 4.x. The dominant risk is not a bug in the billing code; it's that this dependency pins your whole application's Laravel/PHP upgrade path. Name an owner, keep a fork branch, and put the upgrade calendar in the same doc as the runbook.

Webhook signature verification is where correctness lives. Braintree sends bt_signature and bt_payload as POST fields, not a Stripe-style header — the base controller parses via Braintree\WebhookNotification::parse(). Confirm in the source that a bad signature throws and is caught as a 500, not a 200, and that a malformed payload can't throw an unhandled exception. Verify this with a real replay before launch, not by reading the README.

Idempotency is not free. Braintree retries webhooks. If your handler increments a counter, sends an email, or appends a ledger row, wrap it in a dedupe on the notification's timestamp/kind plus subscription ID. The base controller's subscriptions update is naturally idempotent; your side effects are not.

Exclude the webhook route from CSRF. VerifyCsrfToken::$except = ['braintree/webhook']. Miss this and every production webhook 419s while sandbox tests pass — you'll only find out after the first failed renewal.

config:cache will bite you. Braintree credentials read through config('cashier.*') will be frozen after config:cache; rotating a key in .env without config:clear silently keeps the old credential. Put the cache-bust in your deploy script, and be suspicious of "the key is right in .env" bugs.

Braintree\Configuration is static global state. If you ever initialize it yourself (for a one-off charge, a script, or a queued job), you can clobber the package's configuration mid-request. Use the package's accessor — $user->braintree() — rather than calling Braintree_Configuration::environment() directly, and re-initialize defensively in long-running queue workers.

Nonces are single-use. A double-click on the subscribe button, or a replayed request from a retry middleware, will fail with "payment method nonce consumed." Disable the button client-side and guard the server endpoint; don't try to make the nonce reusable.

Trial without a card. braintree_id is nullable so a user can start a trial without a payment method. That means newSubscription(...)->create() will fail if a nonce is required for your plan — decide explicitly whether trials require a card, and whether swap/charge mid-trial works when there's no stored payment method. Test both paths.

Migration conflicts. If users already has billing-adjacent columns (stripe_id, custom plan fields), the published migration will collide. Copy the columns you need into a fresh migration rather than editing the published one — keeps your fork diffable.

Plan IDs live in Braintree's control panel, not in code. There's no Plan::create() here. Keep the plan ID ↔ your entitlement mapping in a config file or enum, and treat a Braintree-side plan deletion as an outage scenario for swap().

Debugging a failed charge. The SDK exception carries the Braintree verification/transaction status — catch it at the controller boundary and log $e->getMessage() plus the transaction ID before returning a generic 422. Without that, your support team has only "payment failed" for what is often a specific AVS or CVV decline reason.

Frozen docs lie by omission. The README is a 2019 copy of Cashier's Stripe-oriented docs. Whenever a method signature matters, read src/Billable.php in your pinned SHA. Any documented behavior you can't find in the source doesn't exist.

Exit is a roadmap item, not a refactor. Decide before billing is load-bearing: fork-and-own, strip the abstraction and call braintree/braintree_php directly, or move to maintained Cashier for Stripe/Paddle. The middle option is usually cheapest — the package is a few thousand lines of trait, and its main value is the shape of the API, which you can borrow without inheriting the dependency.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib