Vatly
Laravel

Customers

Vatly Laravel Package - Customers

Every billable model can be linked to a Vatly customer. Customers are created automatically by Vatly during checkout and synced to your application via webhooks. You can also create customers explicitly if needed.

Creating a customer

// Create a Vatly customer for this user
$user->createAsVatlyCustomer();

// Create with extra data
$user->createAsVatlyCustomer([
    'locale' => 'nl_NL',
    'metadata' => ['internal_id' => $user->id],
]);

Checking customer status

// Check if the user has a Vatly customer ID
$user->hasVatlyId(); // bool

// Get the Vatly customer ID
$user->getVatlyId(); // string|null

Retrieving customer data

// Get the full customer object from the Vatly API
$customer = $user->asVatlyCustomer();

How it works

The vatly_id column on your billable model stores the Vatly customer identifier. When you call createAsVatlyCustomer(), it:

  1. Sends a POST request to the Vatly API to create a customer
  2. Stores the returned customer ID in the vatly_id column
  3. Returns the customer response

If the user already has a vatly_id, calling createAsVatlyCustomer() will throw a CustomerAlreadyBoundException.

Automatic customer creation

When a user starts a checkout without an existing Vatly customer ID, Vatly creates the customer automatically during the checkout flow. The customer ID is synced back to your application via webhooks.

This means you don't need to call createAsVatlyCustomer() before starting a checkout — just redirect the user directly.

Anonymous / guest checkouts

A common flow: a visitor buys before they sign up — checkout completes, Vatly fires order.paid / subscription.started webhooks, and your app has no User row to attribute the purchase to yet.

The package handles this without losing data:

  • The vatly_subscriptions and vatly_orders tables both have a nullable owner_id/owner_type plus a customer_id column. The webhook writes the row with owner_id = null but populates customer_id with the Vatly customer id (cus_…).
  • When the visitor returns on the checkout-success redirect, call claimVatlyCustomerFromReturn() to link those rows to the now-known user.

Learning the checkout on the redirect back: the {CHECKOUT_ID} placeholder

The one hard part of guest checkout is: how does your app know which Vatly customer to claim when the buyer lands back on your site? You don't have to plumb the cus_… through a session or cookie yourself (that approach breaks under multi-tab / double-click — the last write wins, and the buyer may finish the checkout that isn't in the session).

Instead, put the literal {CHECKOUT_ID} placeholder in your return URL. Vatly substitutes it with the checkout's id at creation:

$user->checkout()->create(
    items: [['id' => 'product_abc123', 'quantity' => 1]],
    redirectUrlSuccess: route('vatly.return').'?checkout_id={CHECKOUT_ID}',
    redirectUrlCanceled: route('billing'),
);

On the return route, hand the request to claimVatlyCustomerFromReturn():

public function return(Request $request)
{
    // Reads ?checkout_id=…, resolves that checkout's Vatly customer, and
    // (if there is one) claims it for the authenticated user.
    $request->user()->claimVatlyCustomerFromReturn($request);

    return redirect()->route('dashboard');
}

It returns true when a claim happened and false for a missing / unknown checkout id or a checkout with no customer yet — so it's safe to call unconditionally. Pass a second argument to read a different query key:

$user->claimVatlyCustomerFromReturn($request, 'cid'); // reads ?cid=…

This is multi-tab safe by construction: each tab carries its own checkout id in its own redirect URL, so two checkouts in flight resolve independently — there is no shared carrier whose last write wins.

The checkout id travels in the URL, so it may also appear in the Referer header and your server logs. It is low-sensitivity — it only names a checkout the buyer just completed — but treat it like any URL parameter.

Under the hood claimVatlyCustomerFromReturn() resolves the checkout's customer id via the Vatly API and then runs claimVatlyCustomer(), which:

  1. Binds the Vatly customer id to this host entity via the configured CustomerBindingRepository (default impl: writes vatly_id on the billable table).
  2. Saves the model.
  3. Backfills owner_type / owner_id on every vatly_subscriptions and vatly_orders row that carries the same customer_id but had no owner yet.

The order.paid / subscription.started webhook usually lands before the buyer is redirected back, so those rows already exist (with owner_id = null) by the time you claim — step 3 simply backfills them. The reverse order works too: if the claim runs first, it binds the customer, and a later webhook for the same customer_id finds the binding and writes owner_id directly. Either way the purchase ends up attributed.

If you already hold the cus_… by other means, you can still call claimVatlyCustomer($vatlyCustomerId) directly; it returns the number of rows re-attributed.

Out of scope: buyers who never come back via the redirect link (closed the tab, switched device). Recovering those is the email-based recovery story — a separate concern this helper does not cover.

Need the email / name on the customer to surface a "would you like to claim purchase X?" prompt at signup? Fetch the customer via the Vatly API:

$customer = app(\Vatly\Fluent\Vatly::class)
    ->customers()
    ->findByVatlyCustomerId($vatlyCustomerId);

$customer->email;
$customer->name;
Copyright © 2026