lemonsqueezy/laravel
Official Laravel package for Lemon Squeezy that makes integrating payments simple. Add the Billable trait to models, then handle checkouts, subscriptions, webhooks, and license keys with an expressive, familiar API.
Install and publish the package's config and migrations:
composer require lemonsqueezy/laravel
php artisan vendor:publish --tag="lemon-squeezy-config"
php artisan vendor:publish --tag="lemon-squeezy-migrations"
php artisan migrate
As of 1.9.0 the package supports PHP up to 8.5 and Laravel up to 13, and the hard ext-pcntl requirement has been dropped — so installing on Windows, shared hosting, or a container without pcntl no longer explodes at composer install. If you previously added pcntl to your Dockerfile or composer.json require block purely for this package, you can remove it on the next upgrade.
Add the three required environment variables (test and live credentials are separate in Lemon Squeezy — start with test mode):
LEMON_SQUEEZY_API_KEY=
LEMON_SQUEEZY_STORE_ID=
LEMON_SQUEEZY_WEBHOOK_SECRET=
Add the Billable trait to the model that should own subscriptions — usually User, but any Eloquent model works (teams, workspaces, organizations):
use LemonSqueezy\Laravel\Billable;
class User extends Authenticatable
{
use Billable;
}
Then wire the webhook in the Lemon Squeezy dashboard: Settings → Webhooks → Add endpoint. Point it at https://your-app.test/lemon-squeezy/webhook (confirm the exact path in config/lemon-squeezy.php), select the events you care about (subscription_*, order_created, license_key_created), and paste the signing secret into LEMON_SQUEEZY_WEBHOOK_SECRET. The package registers the route and verifies the X-Signature header for you.
For local development you don't need a public URL: the package ships a listen command that exposes your local app and forwards webhooks to it (run php artisan list | grep -i lemon to confirm the exact signature in your version). Prefer this over ngrok — it also prints the forwarded payloads, which is the fastest way to see what the package actually parses.
First use case: a checkout button. Define a variant ID in Lemon Squeezy, then:
Route::get('/subscribe', function () {
return auth()->user()
->checkout('your-variant-id')
->redirect();
});
checkout() returns a LemonSqueezy\Laravel\Checkout object exposing ->url (for links) and ->redirect() (for controller returns). If that works and the webhook fires on purchase, everything else is composition on top of these two primitives.
Where to look first:
config/lemon-squeezy.php — API key, store ID, webhook secret, route path.vendor/lemonsqueezy/laravel/src/Billable.php — the trait's actual method list.vendor/lemonsqueezy/laravel/src/Events/ — every webhook event class you can listen for.vendor/lemonsqueezy/laravel/src/Commands/ — the listen/expose command and its options.Checkout with custom data. The single most important pattern: stamp your own identifiers onto the checkout so webhooks can resolve the right model. Use the checkout_data.custom field:
$checkout = $user->checkout($variantId, [
'email' => $user->email,
'name' => $user->name,
'checkout_data' => [
'custom' => [
'team_id' => $user->currentTeam->id,
],
],
]);
return $checkout->redirect();
Custom keys are limited and small in size (tens of characters each), so send IDs, not payloads. When you use the Billable trait, the package stamps billable_id/billable_type into the custom data automatically — verify in source if you build checkout URLs by hand.
Webhook listeners. Subscribe to events in your EventServiceProvider:
protected $listen = [
\LemonSqueezy\Laravel\Events\SubscriptionCreated::class => [
ProvisionWorkspace::class,
],
\LemonSqueezy\Laravel\Events\OrderCreated::class => [
SendReceipt::class,
],
\LemonSqueezy\Laravel\Events\LicenseKeyCreated::class => [
StoreLicenseKey::class,
],
];
Listeners receive the event with the parsed payload. Make them ShouldQueue — webhook handling blocks the request otherwise.
Gating features. The Billable trait mirrors Cashier's ergonomics:
if ($user->subscribed()) { // any active subscription
// ...
}
if ($user->subscribed('default')) { // named subscription type
// ...
}
$sub = $user->subscription('default');
$sub->active(); // currently in good standing
$sub->onTrial(); // inside trial window
$sub->onGracePeriod(); // cancelled but still paid-up
$sub->cancelled();
$sub->ended();
Gate UI on onGracePeriod() explicitly — users who cancelled but can still access the product are a common source of "why is my account locked" tickets.
Cancel / resume.
$user->subscription('default')->cancel(); // end of period
$user->subscription('default')->resume(); // undo a pending cancel
There's no built-in proration or plan-swap helper. For upgrades, either create a new checkout for the target variant and cancel the old subscription yourself, or call the underlying API client (LemonSqueezy::api()) with a PATCH /v1/subscriptions/{id}.
License keys. If you sell one-off products, license keys are often the real integration point:
$licenseKey = $user->licenseKeys()->first();
$licenseKey->activate('my-machine-fingerprint'); // increments activation count
$licenseKey->validate(); // returns boolean-ish response
$licenseKey->deactivate('my-machine-fingerprint');
Store activation names so you can deactivate on uninstall — Lemon Squeezy enforces activation limits per-key.
Testing. The API client uses Laravel's HTTP client, so Http::fake() works for checkout and subscription API calls. For webhooks, post a payload with a correct HMAC signature against the same secret:
$payload = file_get_contents(base_path('tests/fixtures/subscription_created.json'));
$signature = hash_hmac('sha256', $payload, config('lemon-squeezy.webhook_secret'));
$this->call('POST', '/lemon-squeezy/webhook', [], [], [], [
'HTTP_X-Signature' => $signature,
'CONTENT_TYPE' => 'application/json',
], $payload);
Keep real webhook fixtures from the dashboard's "send test" button — they encode schema changes.
Test mode is a parallel universe. Test-mode and live-mode stores have different store IDs, API keys, variant IDs, and signing secrets. Store variant IDs in config or a database table keyed by environment, not hard-coded in a Blade file.
Signature verification failures are silent. A 403 on the webhook route almost always means a secret mismatch. The signing secret is not the API key. Also check X-Signature reaches your app — reverse proxies and load balancers sometimes strip custom headers. When developing locally, use the package's listen command rather than hand-rolling a tunnel: it knows the right route and keeps the secret in sync.
The listen command and pcntl. If you're pinned to 1.8.x, the listener required the pcntl extension and failed confusingly without it. On 1.9.0 it's optional — don't carry a stale ext-pcntl requirement or docker-php-ext-install pcntl line forward in your image. Separately, 1.9.0 fixes the regex that parses the exposed tunnel URL, so if you had a workaround for the command misreading the forwarded host/port (or were running an old version because it wouldn't start), you can drop it. If you maintain your own EventServiceProvider discovery or a custom command wrapper, re-test it after upgrading.
Upgrade check for Laravel 13. 1.9.0 is the release that widens the framework constraint to include 13. If you're on Laravel 13 you need at least this version; if you're staying on 10/11/12 there's nothing to migrate. The constraint bump is the only composer change of note — no namespace or config moves in this release.
CSRF. The package's webhook route bypasses the web middleware, so CSRF normally isn't an issue — unless you're on an older Laravel version or have custom middleware groups. If you see 419, exclude the path explicitly in VerifyCsrfToken.
Idempotency, always. Lemon Squeezy retries failed webhooks. order_created and subscription_created can arrive more than once, and out of order. Guard write paths with updateOrCreate keyed on the Lemon Squeezy resource ID; don't assume a subscription_updated follows every subscription_created.
cancelled() is not ended(). A cancelled subscription is still active() during its grace period — cancelled() only means "won't renew." Use ended() for "no longer has access." Getting this backwards either locks people out early or grants free access forever.
Merchant of record means you don't compute tax. Lemon Squeezy handles VAT/sales tax and remits it. Don't build tax logic on top of this package — your invoices are informational receipts, and the numbers on the order webhook are already tax-inclusive.
Custom data shape. checkout_data.custom is a flat map of short strings. Nesting and long values are rejected at checkout creation. If you need richer context, join in your own database on billable_id.
Queue your listeners and make them fast. Webhook endpoints time out; Lemon Squeezy retries on non-2xx. If provisioning a workspace takes 800ms, that's fine — just make sure a provisioning failure throws and retries rather than silently swallowing errors.
Config caching. After changing any LEMON_SQUEEZY_* env var, run php artisan config:cache in production or the old values persist. This bites people switching from test to live.
Overlay component. The package publishes a Blade checkout button/overlay — useful, but it's a thin wrapper around Lemon Squeezy's lemon.js. If you need custom styling or conditional rendering, drop to the ->url variant and build the button yourself rather than fighting the component.
Extension point for anything missing. LemonSqueezy::api() returns the underlying client, giving you raw access to every endpoint in the Lemon Squeezy API. Anything the trait doesn't cover (plan swaps, custom discounts, product sync) lives here — don't fork the package for it.
Multi-tenant billables. Making Team billable instead of User works, but every webhook event resolution depends on the custom data you stamped. Decide early which model owns the subscription; retrofitting is painful because past webhooks already arrived with the old billable_type.
How can I help you explore Laravel packages today?