Subscriptions
Vatly Laravel provides a full subscription lifecycle: creating, checking, swapping plans, canceling, and syncing with the Vatly API.
Starting a subscription
$checkout = $user->subscribe()
->toPlan('subscription_plan_annual')
->create();
return redirect($checkout->links->checkoutUrl->href);
With a free trial
Trials defer the first charge until the trial elapses. Set them on the builder, in whole days or by end date:
// Whole days from checkout creation
$user->subscribe()
->toPlan('subscription_plan_annual')
->withTrialDays(14)
->create();
// Or an end date — the remaining time is rounded *up* to whole days, so the
// trial never ends earlier than requested (Vatly's trial input is day-granular)
$user->subscribe()
->toPlan('subscription_plan_annual')
->withTrialEndsAt(now()->addMonth())
->create();
withTrialDays() throws for a length below 1 day; withTrialEndsAt() throws for a date that isn't in the future. When no trial is set the checkout payload omits trialDays entirely, so any plan-level default at Vatly still applies.
Checking subscription status
// Check if the user is subscribed
$user->subscribed(); // bool
$user->subscribed('premium'); // check a specific subscription type
// Get the subscription as a fluent handle (operations live here)
$subscription = $user->subscription(); // default type, returns ?SubscriptionHandle
$subscription = $user->subscription('premium');
// Get all subscriptions as Eloquent models (state-only)
$subscriptions = $user->subscriptions; // Collection<Subscription>
$user->subscription('default') returns a Vatly\Fluent\SubscriptionHandle — a lightweight wrapper around the underlying Eloquent model that exposes API-driven operations (swap, cancel, sync, updateBilling). Reach the Eloquent model via $subscription->model() if you need it directly.
Subscription state
$subscription->active(); // currently active (including grace period)
$subscription->cancelled(); // has been cancelled
$subscription->onGracePeriod(); // cancelled but still active until ends_at
Swapping plans
// Swap to a new plan (takes effect at the end of the current period by default)
$user->subscription()->swap('subscription_plan_annual');
// Swap and invoice immediately (applies now, charges the prorated delta now)
$user->subscription()->swapAndInvoice('subscription_plan_annual');
Proration options
swap() takes an optional $options array that is passed straight through to the Vatly API. Three flags control how the change is timed and billed:
| Option | Default | Effect |
|---|---|---|
applyImmediately | false | true applies the change now; false applies it when the current period ends |
prorate | true | Credit unused time on the old plan and charge remaining time on the new plan |
invoiceImmediately | false | Only applies when applyImmediately and prorate are both true. true raises a separate invoice for the proration delta right away; false parks the delta on the current cycle and bills it as a line on the next renewal invoice |
// Apply the change now, but defer the prorated charge to the next renewal (the default proration behavior)
$user->subscription()->swap('subscription_plan_annual', ['applyImmediately' => true]);
// Apply now and charge the prorated delta on a separate invoice immediately
$user->subscription()->swap('subscription_plan_annual', [
'applyImmediately' => true,
'invoiceImmediately' => true,
]);
swapAndInvoice() is exactly swap() with applyImmediately and invoiceImmediately both forced to true.
When to charge immediately. With the default invoiceImmediately: false, the customer gets one invoice and one payment covering both the renewal and the parked delta — and if they cancel before that renewal, the parked amount is waived (a customer is never charged at the moment they cancel). That default suits monthly billing, where the delta is collected within weeks. Set invoiceImmediately: true on yearly and other long-interval plans: an upgrade in month 2 of a yearly plan would otherwise not be billed until month 12 (and is waived entirely if the customer cancels in between), so the longer the interval the more the deferral costs you.
Immediate vs. scheduled changes
However the swap is applied, Vatly confirms the outcome over webhooks, and the package keeps the local plan_id / name / quantity in step:
- An immediate change (
applyImmediately: true) raises asubscription.updatedwebhook → theSubscriptionUpdatedevent; the local plan/name/quantity are refreshed. - A change scheduled for the next cycle (
applyImmediately: false) raises asubscription.update_scheduledwebhook → theSubscriptionUpdateScheduledevent. Nothing changes locally yet; the target values ride in the event'sscheduledUpdate(a typedVatly\API\Types\ScheduledSubscriptionUpdateexposingsubscriptionPlanId,name,description,basePrice,quantity,interval,intervalCountandeffectiveAt— the timestamp the change takes effect), and the eventual switch arrives as a latersubscription.updated.
See Webhooks for the full event list.
Canceling
// Cancel the subscription at Vatly
$user->subscription()->cancel();
The actual cancellation is processed via webhooks. Depending on the Vatly configuration, the subscription may:
- End immediately (
SubscriptionCanceledImmediatelyevent) - Enter a grace period (
SubscriptionCanceledWithGracePeriodevent)
A subscription can also be canceled by Vatly, not by your cancel() call: when payment recovery for a failed renewal is exhausted, Vatly hard-cancels it and sends subscription.canceled_for_nonpayment (SubscriptionCanceledForNonpayment). The package ends the local subscription exactly as it does for an immediate cancellation — its ends_at is stamped, canceled() flips to true — so you don't need any special handling to keep local state correct.
Cancellation reason
Vatly tags each cancellation with a reason. The package persists it locally: on any cancellation webhook (subscription.canceled_immediately, subscription.canceled_with_grace_period, subscription.canceled_for_nonpayment) the reason is written to the cancellation_reason column on the vatly_subscriptions row, so you can read it straight off the model:
$user->subscription()->model()->cancellation_reason; // e.g. "payment_failure", or null if not canceled
The same value is on the dispatched event as $event->cancellationReason (Vatly\API\Types\CancellationReason):
| Value | Meaning |
|---|---|
payment_failure | Payment recovery was exhausted after failed renewals (carried by SubscriptionCanceledForNonpayment) |
merchant_request | The merchant canceled the subscription |
customer_request | The customer canceled from the self-service portal |
Listen for the cancellation event to branch dunning / win-back flows off the reason:
use Illuminate\Support\Facades\Event;
use Vatly\API\Types\CancellationReason;
use Vatly\API\Webhooks\Events\SubscriptionCanceledForNonpayment;
Event::listen(SubscriptionCanceledForNonpayment::class, function (SubscriptionCanceledForNonpayment $event) {
// $event->subscriptionId
// $event->customerId
// $event->endsAt
// $event->cancellationReason === CancellationReason::PAYMENT_FAILURE
// Trigger a win-back email, flag the account for re-collection, etc.
});
The local cancellation_reason is only ever set, never cleared, by the webhook reactions — a null reason on an incoming update leaves the stored value untouched. If you also need the live value from Vatly, it's on the API resource as Vatly\API\Resources\Subscription::$cancellationReason (fetched via Vatly::getSubscription()->execute($vatlyId)).
Updating billing details
// Create a signed URL where the customer can update their billing details
// (address, VAT number, company name). Going through the hosted flow also
// refreshes the Mollie mandate as a side effect.
$url = $user->subscription()->updateBilling();
return redirect($url);
Syncing with Vatly
Pull the latest subscription data from the Vatly API:
$user->subscription()->sync();
This updates the local plan_id, name, quantity, ends_at, and trial_ends_at fields.
Subscription types
You can have multiple subscriptions per user by using types:
// A user with both a "default" and "addon" subscription
$user->subscription('default'); // main plan
$user->subscription('addon'); // additional features
The default type is 'default'.