Release v0.1.16 — Phase 16: Laravel Cashier Foundation#223
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cashier-stripe CONV-236: Install Laravel Cashier Stripe
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…migrations-and-add-billable-trait CONV-237: Publish Cashier migrations and add Billable trait
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-environment CONV-238: Configure Stripe environment
Reuses existing App\Enums\Plan (free/pro/max) — no duplicate created. Adds test verifying free/pro/max plan values. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lan-enum CONV-239: Create BillingPlan enum
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lans-config CONV-240: Create billing plans config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lan-dto CONV-241: Create BillingPlan DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lan-repository CONV-242: Create BillingPlanRepository
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ment-service-creates-subscription-checkout CONV-243/244: Test and implement BillingPaymentService subscription checkout
Also adds billing success/cancel routes and minimal views (CONV-246 scope pre-included to support route() resolution in controller). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-checkout-route CONV-245: Add subscription checkout route
Routes and views were pre-added in CONV-245 to support route() resolution. This task adds the feature tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ess-and-cancel-pages CONV-246: Add billing success and cancel pages
Cashier auto-registers /stripe/webhook — smoke test confirms route exists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r-webhook-route CONV-247: Configure Cashier webhook route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ebhook-events-table CONV-248: Create billing webhook events table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ebhook-event-recorder CONV-249: Create BillingWebhookEventRecorder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion-webhook-handler-skeleton CONV-250: Create SubscriptionWebhookHandler skeleton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n-activated-updates-user-plan CONV-251/252: Test and implement subscription activated handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n-cancelled-downgrades-user CONV-253/254: Test and implement subscription cancelled handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e-grants-monthly-credits-once CONV-255/256: Test and implement monthly subscription credit grant
📝 WalkthroughWalkthroughThis PR adds end-to-end subscription billing using Laravel Cashier and Stripe. It introduces billing plan management via DTOs and a config-driven repository, a gateway abstraction for checkout creation, webhook event persistence with idempotency, subscription plan updates triggered by Stripe webhooks, and monthly credit grants locked per-user to serialize concurrent invoice processing. ChangesBilling and Subscription Integration
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly Related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
6 issues found across 46 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php">
<violation number="1" location="database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php:17">
P2: Webhook idempotency is keyed globally by event ID, ignoring provider, which can cause cross-provider event collisions.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| Schema::create('billing_webhook_events', function (Blueprint $table) { | ||
| $table->id(); | ||
| $table->string('provider'); | ||
| $table->string('provider_event_id')->unique(); |
There was a problem hiding this comment.
P2: Webhook idempotency is keyed globally by event ID, ignoring provider, which can cause cross-provider event collisions.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php, line 17:
<comment>Webhook idempotency is keyed globally by event ID, ignoring provider, which can cause cross-provider event collisions.</comment>
<file context>
@@ -0,0 +1,32 @@
+ Schema::create('billing_webhook_events', function (Blueprint $table) {
+ $table->id();
+ $table->string('provider');
+ $table->string('provider_event_id')->unique();
+ $table->string('type');
+ $table->json('payload')->nullable();
</file context>
- P1: Wrap handleInvoicePaid in DB::transaction + lockForUpdate to prevent race condition on concurrent invoice webhook delivery - P2: billing_webhook_events unique constraint changed to composite (provider, provider_event_id) to prevent cross-provider collisions; BillingWebhookEventRecorder updated to use composite key - P2: billing/success and billing/cancel views now extend x-layouts.app with navigation and app styling - P2: Removed string fallbacks from billing_plans.php config; test price IDs moved to phpunit.xml - P2: BillingPaymentService guards against empty-string Stripe price IDs using blank() instead of === null Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix Phase 16 code review issues
|
@CodeRabbit review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
app/Billing/Webhooks/SubscriptionWebhookHandler.php (1)
26-29: 💤 Low valueReplace hardcoded
'free'withApp\Enums\Plan::Freein cancellation.
SubscriptionWebhookHandler::handleSubscriptionCancelled()hardcodes['plan' => 'free'], butUser::$casts['plan']isPlan::classandApp\Enums\Plandefinescase Free = 'free'. Set['plan' => Plan::Free->value](orPlan::Free) instead.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/Billing/Webhooks/SubscriptionWebhookHandler.php` around lines 26 - 29, SubscriptionWebhookHandler::handleSubscriptionCancelled currently force-fills the user's plan with the hardcoded string 'free', which conflicts with User::$casts['plan'] expecting an App\Enums\Plan; update the assignment in handleSubscriptionCancelled to use the enum instead (App\Enums\Plan::Free or App\Enums\Plan::Free->value as appropriate for your cast) so the plan is set via the Plan enum rather than the literal string.database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php (1)
19-19: Consider PII retention for the rawpayload.Stripe webhook payloads (e.g.
invoice.paid,customer.subscription.*) typically embed customer PII such as email, name, and billing address. Persisting the full payload indefinitely in this JSON column may create GDPR/CCPA retention obligations. Consider a retention/pruning policy or storing only the fields you actually need for idempotent processing.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php` at line 19, The migration currently persists the full Stripe webhook JSON via $table->json('payload')->nullable(); which may retain PII indefinitely; change the schema and storage approach to avoid raw payload retention by either (a) replacing or supplementing the payload column with a minimized JSON or specific columns containing only required fields (e.g. event_id, type, customer_id, invoice_id) and remove sensitive fields, or (b) keep payload but mark it encrypted and add retention metadata (e.g. received_at timestamp and a retention_policy flag) and implement a scheduled pruning job to delete or redact payloads older than your retention window; update the migration (create_billing_webhook_events_table) and any write paths that populate payload to produce the minimized/encrypted payload and ensure existing rows are migrated or backfilled according to the chosen policy.app/Billing/BillingPlanRepository.php (1)
36-45: ⚡ Quick winConsider validating required config keys before accessing.
If the
billing_plans.plansconfig is malformed (missinglabel,stripe_price_id,monthly_credits, oris_paid), this method will throw an undefined array key error. Adding validation would produce clearer error messages during development.🛡️ Proposed validation enhancement
private function toDto(string $key, array $data): BillingPlanDto { + $required = ['label', 'stripe_price_id', 'monthly_credits', 'is_paid']; + $missing = array_diff($required, array_keys($data)); + if (!empty($missing)) { + throw new \RuntimeException( + "Billing plan '{$key}' is missing required config keys: " . implode(', ', $missing) + ); + } + return new BillingPlanDto( key: $key, label: $data['label'], stripePriceId: $data['stripe_price_id'], monthlyCredits: $data['monthly_credits'], isPaid: $data['is_paid'], ); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/Billing/BillingPlanRepository.php` around lines 36 - 45, The toDto method currently accesses array keys directly which will raise undefined array key errors if config is malformed; update BillingPlanRepository::toDto to validate that the required keys ('label', 'stripe_price_id', 'monthly_credits', 'is_paid') exist in the $data array before constructing BillingPlanDto, and if any are missing throw a clear InvalidArgumentException (or domain-specific exception) naming the missing keys and the $key identifier so callers/developers get an actionable error message.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/Billing/Gateway/CashierSubscriptionCheckoutGateway.php`:
- Around line 17-24: The code currently assumes $checkout->url contains the
Stripe hosted redirect URL; inspect the Checkout object returned by
$user->newSubscription(...)->checkout(...) (symbol: newSubscription and
checkout) and update the logic to reliably extract the URL (e.g., prefer
$checkout->url if present, otherwise fallback to $checkout->toArray()['url'] or
the Checkout wrapper getter) before constructing CheckoutSessionDto; also detect
if Cashier is using ui_mode 'embedded' or 'custom' and, in that case, ensure the
frontend flow uses return_url semantics rather than relying on a hosted redirect
URL so you don't return an invalid URL from CheckoutSessionDto.
In `@app/Billing/Webhooks/SubscriptionWebhookHandler.php`:
- Line 42: In handleInvoicePaid() replace the risky
$user->creditAccount()->lockForUpdate()->firstOrFail() pattern so the webhook
doesn't throw when a CreditAccount row is missing: either use
lockForUpdate()->firstOrCreate([...]) to create the row under the lock, or
remove the pre-lock/lookup and rely on DatabaseCreditLedger::grant() (which
already does firstOrCreate(...)) to provision the account; ensure the code path
calls creditLedger->grant(...) and no firstOrFail remains so missing
CreditAccount rows no longer cause the webhook to fail.
- Around line 61-67: Change alreadyGrantedForInvoice to accept the current User
and scope the query by user_id, and replace the DB-specific whereRaw JSON check
with Laravel's portable JSON operator: update the signature to
alreadyGrantedForInvoice(User $user, string $invoiceId) and inside use
CreditTransaction::query()->where('reason',
'subscription_monthly_grant')->where('user_id',
$user->id)->where('metadata_json->stripe_invoice_id', $invoiceId)->exists();
then update the call site to pass $user (e.g.
$this->alreadyGrantedForInvoice($user, $invoiceId)).
---
Nitpick comments:
In `@app/Billing/BillingPlanRepository.php`:
- Around line 36-45: The toDto method currently accesses array keys directly
which will raise undefined array key errors if config is malformed; update
BillingPlanRepository::toDto to validate that the required keys ('label',
'stripe_price_id', 'monthly_credits', 'is_paid') exist in the $data array before
constructing BillingPlanDto, and if any are missing throw a clear
InvalidArgumentException (or domain-specific exception) naming the missing keys
and the $key identifier so callers/developers get an actionable error message.
In `@app/Billing/Webhooks/SubscriptionWebhookHandler.php`:
- Around line 26-29: SubscriptionWebhookHandler::handleSubscriptionCancelled
currently force-fills the user's plan with the hardcoded string 'free', which
conflicts with User::$casts['plan'] expecting an App\Enums\Plan; update the
assignment in handleSubscriptionCancelled to use the enum instead
(App\Enums\Plan::Free or App\Enums\Plan::Free->value as appropriate for your
cast) so the plan is set via the Plan enum rather than the literal string.
In
`@database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php`:
- Line 19: The migration currently persists the full Stripe webhook JSON via
$table->json('payload')->nullable(); which may retain PII indefinitely; change
the schema and storage approach to avoid raw payload retention by either (a)
replacing or supplementing the payload column with a minimized JSON or specific
columns containing only required fields (e.g. event_id, type, customer_id,
invoice_id) and remove sensitive fields, or (b) keep payload but mark it
encrypted and add retention metadata (e.g. received_at timestamp and a
retention_policy flag) and implement a scheduled pruning job to delete or redact
payloads older than your retention window; update the migration
(create_billing_webhook_events_table) and any write paths that populate payload
to produce the minimized/encrypted payload and ensure existing rows are migrated
or backfilled according to the chosen policy.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 0c5440d4-073d-4d28-9fa1-6bfdd1b4e22f
⛔ Files ignored due to path filters (1)
composer.lockis excluded by!**/*.lock
📒 Files selected for processing (46)
.env.exampleapp/Billing/BillingPaymentService.phpapp/Billing/BillingPlanDto.phpapp/Billing/BillingPlanRepository.phpapp/Billing/CheckoutSessionDto.phpapp/Billing/Exceptions/CannotCheckoutFreePlanException.phpapp/Billing/Exceptions/UnknownBillingPlanException.phpapp/Billing/Gateway/CashierSubscriptionCheckoutGateway.phpapp/Billing/Gateway/FakeSubscriptionCheckoutGateway.phpapp/Billing/Gateway/SubscriptionCheckoutGateway.phpapp/Billing/Webhooks/BillingWebhookEventRecorder.phpapp/Billing/Webhooks/SubscriptionWebhookHandler.phpapp/Http/Controllers/Billing/StartSubscriptionCheckoutController.phpapp/Models/BillingWebhookEvent.phpapp/Models/User.phpapp/Providers/AppServiceProvider.phpcomposer.jsonconfig/billing_plans.phpconfig/cashier.phpdatabase/factories/BillingWebhookEventFactory.phpdatabase/migrations/2026_06_02_143126_create_customer_columns.phpdatabase/migrations/2026_06_02_143127_create_subscriptions_table.phpdatabase/migrations/2026_06_02_143128_create_subscription_items_table.phpdatabase/migrations/2026_06_02_143129_add_meter_id_to_subscription_items_table.phpdatabase/migrations/2026_06_02_143130_add_meter_event_name_to_subscription_items_table.phpdatabase/migrations/2026_06_02_144415_create_billing_webhook_events_table.phpdatabase/migrations/2026_06_02_151136_update_billing_webhook_events_unique_to_composite.phpphpunit.xmlresources/views/billing/cancel.blade.phpresources/views/billing/success.blade.phproutes/web.phptests/Feature/Billing/BillingPaymentServiceTest.phptests/Feature/Billing/BillingResultPagesTest.phptests/Feature/Billing/BillingWebhookEventRecorderTest.phptests/Feature/Billing/BillingWebhookEventTest.phptests/Feature/Billing/CashierWebhookRouteTest.phptests/Feature/Billing/SubscriptionCheckoutRouteTest.phptests/Feature/Billing/SubscriptionMonthlyCreditGrantTest.phptests/Feature/Billing/SubscriptionWebhookHandlerTest.phptests/Unit/Billing/BillingPlanDtoTest.phptests/Unit/Billing/BillingPlanRepositoryTest.phptests/Unit/Billing/BillingPlanTest.phptests/Unit/Billing/BillingPlansConfigTest.phptests/Unit/Billing/CashierBillableTest.phptests/Unit/Billing/CashierConfigTest.phptests/Unit/Billing/SubscriptionWebhookHandlerSkeletonTest.php
| $checkout = $user | ||
| ->newSubscription('default', $plan->stripePriceId) | ||
| ->checkout([ | ||
| 'success_url' => $successUrl, | ||
| 'cancel_url' => $cancelUrl, | ||
| ]); | ||
|
|
||
| return new CheckoutSessionDto(url: $checkout->url); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Laravel Cashier 16 newSubscription checkout method return value and how to get the hosted checkout URL (url property)
💡 Result:
Laravel Cashier (cashier-stripe) v16: newSubscription(...)->checkout does NOT return a hosted checkout URL directly. 1) What checkout returns (v16) - In Cashier’s subscription checkout, the checkout call returns a Checkout object (Cashier’s wrapper around a Stripe Checkout Session). The Checkout object includes the Stripe session data such as the session id/url, but the method you call returns that Checkout wrapper rather than an already-built URL string/redirect response. [1][2] 2) How to get the hosted Stripe Checkout URL (the “url” property) - Convert the returned Checkout object to an array and read the url key. Example: $checkout = $request->user ->newSubscription('default', 'price_basic_monthly') ->checkout([ 'success_url' => route('checkout-success'), 'cancel_url' => route('checkout-cancel'), ]); $url = $checkout->toArray['url']; return redirect->away($url); This matches the documented behavior that Cashier’s provided Checkout wrapper exposes a url value via its serialized/array representation. [3] 3) Important note about “hosted checkout URL” vs embedded/custom UI - If you’re using Stripe Checkout UI modes, Cashier’s checkout options change what URL/redirect behavior you can expect (success_url/cancel_url vs return_url) depending on embedded UI/no-redirect scenarios. Cashier explicitly handles return_url vs success_url/cancel_url differences based on ui_mode and redirect_on_completion. [4][5] If you tell me whether you’re using the default hosted checkout UI mode or ui_mode=embedded/custom (and whether redirect_on_completion is set), I can tailor the exact snippet/option set for that mode.
Citations:
- 1: https://github.com/laravel/docs/blob/12.x/billing.md
- 2: https://stackoverflow.com/questions/75167623/laravel-cashier-checkout-results-in-an-empty-page
- 3: Return Stripe checkout URL from checkout method laravel/framework#49801
- 4: UI mode 'custom' is not working when creating checkout session laravel/cashier-stripe#1780
- 5: [15.x] Fix unset 'return_url' for embedded UI without redirection laravel/cashier-stripe#1631
Confirm Cashier subscription checkout URL access ($checkout->url)
newSubscription(...)->checkout([...])returns Cashier’sCheckoutwrapper (not a raw URL string); the hosted Stripe Checkout redirect URL comes from the underlying Stripe Checkout Session (e.g. via$checkout->toArray()['url'], and commonly as$checkout->urlas well).- For
ui_mode=embedded/custom, Cashier swapssuccess_url/cancel_urlforreturn_url; ensure the frontend/redirect flow matches that mode so you don’t rely on hosted checkout semantics that don’t apply there.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/Billing/Gateway/CashierSubscriptionCheckoutGateway.php` around lines 17 -
24, The code currently assumes $checkout->url contains the Stripe hosted
redirect URL; inspect the Checkout object returned by
$user->newSubscription(...)->checkout(...) (symbol: newSubscription and
checkout) and update the logic to reliably extract the URL (e.g., prefer
$checkout->url if present, otherwise fallback to $checkout->toArray()['url'] or
the Checkout wrapper getter) before constructing CheckoutSessionDto; also detect
if Cashier is using ui_mode 'embedded' or 'custom' and, in that case, ensure the
frontend flow uses return_url semantics rather than relying on a hosted redirect
URL so you don't return an invalid URL from CheckoutSessionDto.
- Replace firstOrFail() with firstOrCreate() on credit account lock: avoids ModelNotFoundException when credit account is missing (e.g. UserObserver failed); account is provisioned under the lock - Replace SQLite-specific whereRaw json_extract with portable Laravel JSON operator (metadata_json->stripe_invoice_id) and scope alreadyGrantedForInvoice by user_id - Use Plan::Free enum instead of hardcoded 'free' string in handleSubscriptionCancelled for type consistency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix SubscriptionWebhookHandler followup issues
Phase 16 — Laravel Cashier Foundation
Completes CONV-236 → CONV-256.
What's in this release
Cashier Foundation (CONV-236–238)
Usermodel usesBillabletrait.env.exampleandphpunit.xml(test placeholders)Billing Domain (CONV-239–242)
App\Enums\Planreused (no duplicate created)config/billing_plans.php— free (50cr), pro (1000cr), max (5000cr) with Stripe price ID env varsBillingPlanDto— readonly DTOBillingPlanRepository— typed access to plans config, throwsUnknownBillingPlanExceptionPayment Service (CONV-243–246)
SubscriptionCheckoutGatewayinterface +CashierSubscriptionCheckoutGateway(prod) +FakeSubscriptionCheckoutGateway(tests)BillingPaymentService— creates subscription checkout, rejects free planPOST /billing/checkout/{plan}route — auth-protected, delegates to service/billing/successand/billing/cancelpages — auth-protectedWebhooks (CONV-247–256)
/stripe/webhookauto-registered by Cashierbilling_webhook_eventstable with uniqueprovider_event_idBillingWebhookEventRecorder— idempotent event recordingSubscriptionWebhookHandler:handleSubscriptionActivated→ updatesuser.planhandleSubscriptionCancelled→ downgrades to free (MVP policy)handleInvoicePaid→ grants monthly credits viaCreditLedger, idempotent by invoice IDWhat's NOT in this release
Test plan
composer testpasses (459 tests)composer lintpassesnpm run buildpassesphp artisan migrate:fresh --seedpasses🤖 Generated with Claude Code
Summary by cubic
Sets up the billing foundation with
laravel/cashierand Stripe subscriptions: plan config, checkout, and robust webhooks that update user plans and grant monthly credits with safer idempotency. Completes CONV-236→CONV-256.New Features
/billing/checkout/{plan}for signed-in users; success and cancel pages./stripe/webhook; idempotent event recorder with composite (provider, provider_event_id) key; invoice grants wrapped in a DB transaction with row lock and firstOrCreate on the credit account to prevent duplicates; portable JSON lookup for invoice ID; activation sets plan, cancel downgrades toPlan::Free.Usermodel now uses CashierBillable.Migration
/stripe/webhookwith Cashier’s default events.Written for commit 10301af. Summary will update on new commits.
Summary by CodeRabbit