silentz/charge
Silentz Charge is a Laravel package for simple charge and payment tracking. It provides Eloquent models, migrations, and helpers to record charges, associate them with users, and manage balances, making lightweight billing workflows easier without a full subscription system.
silentz/charge is a Statamic addon that wires Laravel Cashier (Stripe) into Statamic: Stripe Checkout, the customer billing portal, subscription-aware Antlers tags, and content gating by plan. With 4 stars, 0 dependents, and an archived repository, treat it as a reference implementation or a fork, not a dependency you install and forget. The fastest route to value is usually to read its source and copy the ~300 lines you need into your app, or vendor a fork you control.
Minimal steps (verify tag/route names against the service provider — the repo is archived, so README drift is likely):
composer.json for supported statamic/cms, laravel/framework, and laravel/cashier constraints against your project. Statamic 5 / Laravel 11 projects often need manual constraint bumps."repositories": [{ "type": "vcs", "url": "https://github.com/<you>/statamic-charge" }],
"require": { "silentz/charge": "dev-main" }
php artisan vendor:publish --provider="Silentz\Charge\ServiceProvider"
php artisan migrate
Look for config/charge.php and any Cashier migrations the provider loads.STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, plus your price IDs.Billable trait. Statamic's default file-based users will not work — you need config/auth.php pointed at an Eloquent user repository and the trait applied.config/charge.php (a handle → Stripe price ID map). Treat this file as the single source of truth for plan handles used in templates and middleware./charge/webhook or /stripe/webhook — check routes/ in the package) and exclude it from CSRF.Where to look first, in order: README.md → config/charge.php → routes/*.php → src/Http/Controllers/ (checkout, portal, webhook) → src/Tags/ (Antlers tags) → src/ServiceProvider.php (what's actually registered). The service provider tells you the truth faster than the docs.
First use case: gate a members-only collection behind an active subscription and add a "Subscribe" button. That exercises the whole loop — checkout, webhook, subscription state, authorization — in one sitting.
Plan-gated routes via middleware. The canonical pattern is a route group middleware (e.g. charge.subscribed:pro) around protected Statamic routes:
Route::middleware(['web', 'charge.subscribed:pro'])->group(function () {
Route::statamic('/members/{slug}', 'members/show');
});
Register aliases in app/Http/Kernel.php / bootstrap/app.php if the package doesn't auto-alias on your Laravel version.
Antlers-side gating. Use the package's tags for conditional UI rather than hiding pages only in routes — you want both, since collection listings leak titles otherwise:
{{ if logged_in }}
{{ charge:subscribed plan="pro" }}
<a href="/members">Members area</a>
{{ else }}
{{ charge:checkout plan="pro" }}<button>Upgrade</button>{{ /charge:checkout }}
{{ /charge:subscribed }}
{{ /if }}
Verify the exact tag namespace (charge: vs subscriptions:) in src/Tags/; this is the most common source of "tag does nothing" confusion.
Checkout as a POST, not a link. Stripe Checkout sessions should be created server-side and redirected to. If the package exposes a form tag, post to it; if not, add a small controller calling $user->newSubscription('default', $priceId)->checkout([...]). Keep success/cancel URLs in config, not hardcoded in templates.
Billing portal for self-service. Route /billing to $user->billingPortalUrl(route('account')). This removes 90% of "how do I cancel?" support tickets and is the highest-ROI screen to build first.
Webhook handling — don't duplicate Cashier. Charge's webhook controller ultimately calls Cashier's WebhookController (or reimplements it). If you need custom behavior (provisioning, emails, Slack pings), listen to Cashier events (Cashier\Events\WebhookReceived, WebhookHandled) or Stripe events rather than editing the controller. Forking the webhook is the #1 way to break idempotency.
Testing pattern. Use Cashier's test helpers plus Stripe CLI locally:
stripe listen --forward-to localhost:8000/charge/webhook
In feature tests, $user->subscriptions()->create([...]) directly rather than mocking Stripe; only hit the Stripe API in a thin integration test.
Integration tips:
charge.subscribed answers "paying?", permission: answers "allowed?" — compose both.config/charge.php and render it in pricing pages so marketing copy and Stripe IDs never diverge.protect route group for the auth half and Charge middleware for the billing half.Team model instead of User; check for a config key before assuming.Archived status is the headline gotcha. No upstream fixes, no Statamic 5/6 compatibility promises, no security patches. Concretely: pin the exact commit in composer.json, commit composer.lock, and budget a half-day to vendor the package into app/ if you plan to keep it past one release cycle. The "opportunity" here is real — a maintained Cashier-for-Statamic addon is a gap.
File-based users silently fail. Cashier's Billable trait needs Eloquent. If config/statamic/users.php uses the file repository, subscriptions can't be stored on the user and every check returns false. Switch to the Eloquent repository (or a custom one backed by Eloquent) before debugging anything else.
Webhook 419s. The Stripe webhook route must bypass CSRF. If the package registers it under web middleware without an exclusion, you'll see 419s in Stripe's dashboard and subscriptions that "work" in checkout but never persist. Verify in routes/ and add VerifyCsrfToken::$except.
Webhook secret mismatch is the silent killer. Signature verification failures return 400 and are easy to miss because checkout still redirects fine. Always run stripe listen locally with the CLI-provided secret, and log WebhookReceived during bring-up.
Migration collisions. Cashier migrations may already exist in your app (subscriptions, subscription_items, customer_columns). Publishing Charge's copies will throw "table already exists". Diff the migration filenames before running, and never re-publish after your first migrate.
Config caching. Plan maps in config/charge.php are read at boot. After editing plans, php artisan config:clear — a stale cached config is the usual reason a price ID "isn't found."
Trial and grace-period nuance. $user->subscribed() is not the same as $user->onTrial() or $user->onGracePeriod(). Decide explicitly whether trials get access (usually yes) and whether cancelled-but-not-expired users do (usually yes until ends_at). Encode that decision in one place — a hasAccess() method on the user or a small policy — rather than scattering Antlers conditionals.
Extension points to prefer: Cashier events, a custom middleware wrapping the package's, and overriding the package's views via vendor:publish --tag=charge-views. Avoid subclassing controllers; route model binding and middleware assumptions make it brittle.
Debugging checklist, in order: (1) Is the user Eloquent and billable? (2) Does the subscriptions row exist after checkout? (3) Did the webhook return 200 in stripe events resend? (4) Is the plan handle in config exactly matching the template/middleware string? (5) Is config cached? This sequence resolves the large majority of issues with this class of addon.
Verdict for day-to-day use: viable for a small memberships site where you're willing to fork and maintain it; not appropriate for anything where billing correctness is business-critical and you can't own the code. In that case, the pragmatic move is to skip the package entirely and wire Cashier into Statamic directly — it's a few routes, a middleware, and one webhook, and you keep the upgrade path.
How can I help you explore Laravel packages today?