We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Laravel Cashier for Phoenix: Stripe Subscriptions and Usage Billing in Elixir
By Liam Killingback ·
Laravel Cashier for Phoenix: Stripe Subscriptions and Usage Billing in Elixir
If you have shipped a Laravel SaaS, you have typed something close to $user->newSubscription('default', 'price_pro')->create($paymentMethod) and then moved on with your day. Cashier created the Stripe customer, stored the subscription, swallowed the webhooks, tracked the trial, and gave you $user->subscribed() to gate features with. It is one of the best pieces of first-party billing tooling in any framework.
Then you try Phoenix. You run mix phx.new, you get auth from phx.gen.auth, and for billing you get a blank file. There is no Cashier. There is no Billable trait, no subscribed() helper, no hasIncompletePayment(). That gap is one of the most common reasons Laravel developers stall on Phoenix for commercial work, and it is worth being honest about: the gap is real.
This post is the practical answer to “what is the Laravel Cashier for Phoenix”. There is no single package that matches it feature for feature, so instead we will build the parts that matter, in idiomatic Elixir, with real code. By the end you will have a billable schema, Checkout, a webhook pipeline that is actually the source of truth, plan gating, trials, cancellations with a grace period, and usage-based billing. We will also be clear about what Cashier does that you will have to write yourself.
What Laravel Cashier actually gives you
Strip away the marketing and Cashier is four things:
-
A billable model. A trait that attaches a Stripe customer ID to your
UserorTeam, plussubscriptionsandsubscription_itemstables with a fixed shape. - A fluent builder for subscription lifecycle. Create, swap, resume, cancel, cancel at period end, and add quantity.
- A webhook controller you never look at. It ships mounted, verifies signatures, and keeps the local subscription rows in sync with Stripe.
-
Query helpers.
subscribed(),subscribedToPrice(),onTrial(),onGracePeriod(),hasIncompletePayment(), plus invoice listing and a redirect into the Stripe billing portal.
Where Cashier genuinely wins
Being fair about this matters, because half the reason people ask for a Phoenix equivalent is that they have felt the difference.
- Time to first charge. Cashier gets you to a working subscription in an afternoon. A hand-rolled Phoenix billing context is realistically two to four days of work before it is trustworthy.
- Edge cases already discovered. Incomplete payments, SCA authentication redirects, proration on plan swaps, grace periods, resuming a cancelled subscription before it lapses. Cashier encodes years of bug reports from thousands of apps.
- Invoice presentation. Cashier renders downloadable invoice PDFs out of the box with your company details on them.
- Two payment providers. Cashier Stripe and Cashier Paddle share an API surface, which is genuinely useful if you sell to the EU as merchant of record.
If your entire billing model is “three flat monthly plans, Stripe, ship it”, Laravel plus Cashier is faster than Phoenix. Say that out loud, then look at what you get in return.
Why Phoenix has no Cashier, and why that is only half a problem
Cashier works because Laravel is opinionated all the way down: Eloquent models, a global User, traits that inject behaviour, a service container to swap implementations. Phoenix deliberately refuses most of that. Contexts are plain modules, schemas are plain structs, and there is no trait mechanism to bolt subscribed() onto your user.
The practical consequence is that a Phoenix “Cashier” would have to be a code generator rather than a library, because it needs to write into your context boundaries. That is exactly what a good starter template is, and it is why the Elixir answer to this question has tended to be a template rather than a hex package.
The upside, and it is a large one, is that the parts of billing that hurt most at scale are the parts the BEAM is unusually good at: counting usage concurrently without hammering Postgres, reconciling webhooks with supervised retries, and pushing live usage to a dashboard over PubSub. Metering is the hard half of modern billing, and this is where Phoenix pulls ahead. Our complete guide to usage-based billing in Elixir and Phoenix covers that argument in depth.
The Phoenix equivalent, piece by piece
1. The billable schema
Cashier’s Billable trait becomes an ordinary stripe_customer_id column on whatever you bill, plus a subscriptions table. Bill the account or organisation, not the user, unless you are certain you will never sell to a team.
defmodule MyApp.Billing.Subscription do
use Ecto.Schema
import Ecto.Changeset
@statuses ~w(trialing active past_due canceled incomplete incomplete_expired unpaid paused)
schema "subscriptions" do
field :stripe_id, :string
field :stripe_status, :string
field :stripe_price_id, :string
field :quantity, :integer, default: 1
field :trial_ends_at, :utc_datetime
field :ends_at, :utc_datetime
field :current_period_end, :utc_datetime
belongs_to :account, MyApp.Accounts.Account
timestamps(type: :utc_datetime)
end
def changeset(sub, attrs) do
sub
|> cast(attrs, [
:stripe_id, :stripe_status, :stripe_price_id, :quantity,
:trial_ends_at, :ends_at, :current_period_end, :account_id
])
|> validate_required([:stripe_id, :stripe_status, :account_id])
|> validate_inclusion(:stripe_status, @statuses)
|> unique_constraint(:stripe_id)
end
end
The ends_at column is Cashier’s grace period in disguise. When a customer cancels, Stripe keeps the subscription active until the period ends. You store that timestamp and keep serving them until it passes.
2. A tiny Stripe client with Req
You do not need a large SDK. Stripe’s API is form-encoded HTTP, and Req handles it in about thirty lines.
defmodule MyApp.Billing.Stripe do
@base "https://api.stripe.com/v1"
def post(path, form, opts \\ []) do
req()
|> Req.post(url: path, form: form, headers: idempotency(opts))
|> handle()
end
def get(path, params \\ []) do
req() |> Req.get(url: path, params: params) |> handle()
end
defp req do
Req.new(
base_url: @base,
auth: {:bearer, Application.fetch_env!(:my_app, :stripe_secret_key)},
receive_timeout: 15_000,
retry: :transient
)
end
defp idempotency(opts) do
case Keyword.get(opts, :idempotency_key) do
nil -> []
key -> [{"idempotency-key", key}]
end
end
defp handle({:ok, %{status: status, body: body}}) when status in 200..299, do: {:ok, body}
defp handle({:ok, %{body: %{"error" => err}}}), do: {:error, err}
defp handle({:error, reason}), do: {:error, reason}
end
Pass an idempotency key on anything that creates money movement. Stripe will collapse the retry rather than double charging, which matters because retry: :transient means Req will retry for you.
3. Creating a subscription
Cashier’s newSubscription(...)->create(...) collects the card itself. In 2026 the sane default is Stripe Checkout, so the card never touches your app and SCA is Stripe’s problem.
defmodule MyApp.Billing do
alias MyApp.Billing.{Stripe, Subscription}
def checkout_session(account, price_id, urls) do
with {:ok, customer_id} <- ensure_customer(account) do
Stripe.post("/checkout/sessions", [
mode: "subscription",
customer: customer_id,
client_reference_id: account.id,
"line_items[0][price]": price_id,
"line_items[0][quantity]": 1,
"subscription_data[trial_period_days]": 14,
allow_promotion_codes: true,
success_url: urls.success <> "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: urls.cancel
])
end
end
def ensure_customer(%{stripe_customer_id: id} = _account) when is_binary(id), do: {:ok, id}
def ensure_customer(account) do
with {:ok, %{"id" => id}} <-
Stripe.post("/customers", [
email: account.billing_email,
name: account.name,
"metadata[account_id]": account.id
]),
{:ok, _} <- MyApp.Accounts.update_account(account, %{stripe_customer_id: id}) do
{:ok, id}
end
end
end
From LiveView, redirect to the returned URL:
def handle_event("subscribe", %{"price" => price_id}, socket) do
case Billing.checkout_session(socket.assigns.account, price_id, checkout_urls(socket)) do
{:ok, %{"url" => url}} -> {:noreply, redirect(socket, external: url)}
{:error, _} -> {:noreply, put_flash(socket, :error, "Could not start checkout.")}
end
end
4. Webhooks are the source of truth
This is the single most important thing to copy from Cashier, and the thing hand-rolled billing gets wrong most often. Do not write the subscription row when Checkout returns. The user closes the tab, the redirect fails, the network drops. Write it when Stripe tells you it exists.
First verify the signature against the raw body. Phoenix parses the body before your controller sees it, so you need a custom body reader that stashes the raw payload for the webhook path only.
defmodule MyApp.Billing.Webhooks do
@tolerance 300
def verify(payload, sig_header, now \\ System.system_time(:second)) do
with {:ok, ts, signatures} <- parse(sig_header),
true <- abs(now - ts) <= @tolerance,
expected <- digest(ts, payload),
true <- Enum.any?(signatures, &Plug.Crypto.secure_compare(&1, expected)) do
{:ok, Jason.decode!(payload)}
else
_ -> {:error, :invalid_signature}
end
end
defp digest(ts, payload) do
:crypto.mac(:hmac, :sha256, secret(), "#{ts}.#{payload}") |> Base.encode16(case: :lower)
end
defp parse(header) do
parts = for pair <- String.split(header, ","), [k, v] = String.split(pair, "=", parts: 2), do: {k, v}
case List.keyfind(parts, "t", 0) do
{"t", ts} -> {:ok, String.to_integer(ts), for({"v1", v} <- parts, do: v)}
_ -> :error
end
end
defp secret, do: Application.fetch_env!(:my_app, :stripe_webhook_secret)
end
The controller does as little as possible. Acknowledge fast, process in Oban, because Stripe will retry you if you are slow and you would rather retry on your own terms.
defmodule MyAppWeb.StripeWebhookController do
use MyAppWeb, :controller
def create(conn, _params) do
with [sig] <- get_req_header(conn, "stripe-signature"),
{:ok, event} <- MyApp.Billing.Webhooks.verify(conn.assigns.raw_body, sig),
{:ok, _job} <- enqueue(event) do
send_resp(conn, 200, "")
else
_ -> send_resp(conn, 400, "")
end
end
defp enqueue(%{"id" => id} = event) do
%{event_id: id, event: event}
|> MyApp.Billing.EventWorker.new(unique: [keys: [:event_id], period: :infinity])
|> Oban.insert()
end
end
The unique option is your deduplication. Stripe sends events at least once, and a plan change that fires twice should not double the customer’s quota.
The worker reduces events onto local state:
defmodule MyApp.Billing.EventWorker do
use Oban.Worker, queue: :billing, max_attempts: 10
alias MyApp.Billing
@impl true
def perform(%Oban.Job{args: %{"event" => %{"type" => type, "data" => %{"object" => object}}}}) do
case type do
"customer.subscription." <> _ -> Billing.sync_subscription(object)
"invoice.payment_failed" -> Billing.flag_payment_failure(object)
"invoice.payment_succeeded" -> Billing.clear_payment_failure(object)
_ -> :ok
end
end
end
And sync_subscription/1 is a plain upsert. Stripe’s object is authoritative, so never merge, always replace.
def sync_subscription(%{"id" => stripe_id} = object) do
attrs = %{
stripe_id: stripe_id,
stripe_status: object["status"],
stripe_price_id: get_in(object, ["items", "data", Access.at(0), "price", "id"]),
quantity: get_in(object, ["items", "data", Access.at(0), "quantity"]) || 1,
trial_ends_at: from_unix(object["trial_end"]),
current_period_end: from_unix(object["current_period_end"]),
ends_at: if(object["cancel_at_period_end"], do: from_unix(object["current_period_end"])),
account_id: account_id_for(object)
}
%Subscription{}
|> Subscription.changeset(attrs)
|> Repo.insert(on_conflict: {:replace_all_except, [:id, :inserted_at]}, conflict_target: :stripe_id)
end
defp from_unix(nil), do: nil
defp from_unix(ts), do: DateTime.from_unix!(ts) |> DateTime.truncate(:second)
5. The query helpers
Cashier’s subscribed() and friends become functions on your billing context. This is the part Laravel developers miss most, and it takes about twenty lines.
defmodule MyApp.Billing do
@active ~w(active trialing past_due)
def active_subscription(account) do
Subscription
|> where([s], s.account_id == ^account.id and s.stripe_status in @active)
|> order_by([s], desc: s.inserted_at)
|> limit(1)
|> Repo.one()
end
def subscribed?(account), do: active_subscription(account) != nil
def subscribed_to_price?(account, price_id) do
match?(%Subscription{stripe_price_id: ^price_id}, active_subscription(account))
end
def on_trial?(account) do
case active_subscription(account) do
%Subscription{trial_ends_at: %DateTime{} = at} -> DateTime.after?(at, DateTime.utc_now())
_ -> false
end
end
def on_grace_period?(account) do
case active_subscription(account) do
%Subscription{ends_at: %DateTime{} = at} -> DateTime.after?(at, DateTime.utc_now())
_ -> false
end
end
end
Resist the urge to check subscribed?/1 in your templates. Gate in the context function that performs the action, so an API request and a LiveView click hit the same rule. We wrote up the full pattern, including an on_mount hook and an ETS cache for the resolved plan, in plan-based feature gating and entitlements in Phoenix.
6. Swapping plans, cancelling, resuming
def swap(%Subscription{} = sub, new_price_id) do
with {:ok, %{"items" => %{"data" => [%{"id" => item_id} | _]}}} <-
Stripe.get("/subscriptions/#{sub.stripe_id}") do
Stripe.post("/subscriptions/#{sub.stripe_id}", [
"items[0][id]": item_id,
"items[0][price]": new_price_id,
proration_behavior: "create_prorations"
])
end
end
def cancel_at_period_end(%Subscription{} = sub) do
Stripe.post("/subscriptions/#{sub.stripe_id}", [cancel_at_period_end: true])
end
def resume(%Subscription{} = sub) do
Stripe.post("/subscriptions/#{sub.stripe_id}", [cancel_at_period_end: false])
end
Notice that none of these write to your database. The webhook does that. One writer, one source of truth, no drift.
7. Invoices and the billing portal
Cashier renders invoice PDFs itself. Do not rebuild that. Stripe’s hosted billing portal handles payment method updates, invoice history, plan changes, and cancellation, and it stays compliant without you maintaining it.
def portal_url(account, return_url) do
with {:ok, customer_id} <- ensure_customer(account),
{:ok, %{"url" => url}} <-
Stripe.post("/billing_portal/sessions", customer: customer_id, return_url: return_url) do
{:ok, url}
end
end
That is one function against Cashier’s invoice templating layer. It is also less code you own.
8. Usage-based billing, where Phoenix pulls ahead
Cashier’s usage support is thin. You call reportUsage() and Laravel forwards it to Stripe, which means a synchronous HTTP call on a request path that may fire thousands of times a minute. Most Laravel teams end up building a queue and a counter table in front of it anyway.
On the BEAM you get the right architecture almost for free. Count in ETS on the hot path, flush to Postgres on a timer, report to Stripe from Oban.
defmodule MyApp.Billing.Meter do
use GenServer
@table :usage_counters
@flush_every :timer.seconds(30)
def record(account_id, event, value \\ 1) do
:ets.update_counter(@table, {account_id, event}, value, {{account_id, event}, 0})
end
def start_link(_), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__)
@impl true
def init(_) do
:ets.new(@table, [:named_table, :public, :set, write_concurrency: true])
:timer.send_interval(@flush_every, :flush)
{:ok, nil}
end
@impl true
def handle_info(:flush, state) do
@table
|> :ets.tab2list()
|> Enum.each(fn {{account_id, event} = key, count} ->
:ets.update_counter(@table, key, -count)
MyApp.Billing.Usage.persist(account_id, event, count)
end)
{:noreply, state}
end
end
:ets.update_counter/4 is an atomic write with no process in the path, so record/3 costs roughly a microsecond and never becomes a bottleneck under load. That is a genuinely different starting point from a PHP request lifecycle. The full version, including quota enforcement in a plug and multi-node behaviour, is in Phoenix usage metering in real time with ETS, and the reporting side is in Stripe metered billing in Elixir.
Side by side: Cashier to Phoenix
| Laravel Cashier | Phoenix equivalent |
|---|---|
Billable trait |
stripe_customer_id column plus a MyApp.Billing context |
newSubscription()->create() |
Stripe Checkout session, subscription written by webhook |
$user->subscribed() |
Billing.subscribed?(account) |
subscribedToPrice() |
Billing.subscribed_to_price?(account, price_id) |
onTrial() / onGracePeriod() |
Billing.on_trial?/1 / Billing.on_grace_period?/1 |
swap() / cancel() / resume() |
Thin Stripe calls, state synced by webhook |
WebhookController |
Verify plus enqueue, reduce events in an Oban worker |
invoices() and PDF rendering |
Stripe hosted billing portal |
reportUsage() |
ETS counter, timed flush, Oban reporting to Billing Meters |
Cashier::Paddle |
Nothing equivalent. Write it or stay on Stripe. |
What you still have to build yourself
Being honest about the remaining gap:
-
Tax. Cashier wires Stripe Tax with a config flag. You will pass
automatic_tax[enabled]=trueyourself and test it. - Invoice PDFs with your branding. Use the hosted portal, or generate them yourself.
- Paddle or another merchant of record. No shared abstraction exists in Elixir.
-
Multiple concurrent subscriptions per customer. Cashier names subscriptions (“default”, “swimming”). Add a
namecolumn if you need it. -
Incomplete payment recovery flows. Handle
incompletestatus and theinvoice.payment_action_requiredevent explicitly.
Budget two to four days for a careful implementation, plus a day of testing against Stripe test mode and the CLI (stripe listen --forward-to localhost:4000/webhooks/stripe).
Testing without touching Stripe
Stub the client at the HTTP layer so your tests exercise the real code paths.
test "webhook activates the subscription", %{account: account} do
event = stripe_event("customer.subscription.created", %{
"id" => "sub_123",
"status" => "active",
"current_period_end" => 1_800_000_000,
"metadata" => %{"account_id" => account.id}
})
assert {:ok, _} = Billing.sync_subscription(event["data"]["object"])
assert Billing.subscribed?(account)
end
Test the reducer directly rather than through the controller, then add one controller test that a bad signature returns 400. That combination catches almost everything that matters.
Skip the plumbing
Everything above is a few days of careful work, and it is the work Laravel developers were hoping not to redo. Two shortcuts:
- For subscriptions, auth, transactional email and the admin surface already wired together, phx_saas ships the Stripe Checkout flow, the signed webhook pipeline and the plan gating as working code you own outright.
-
For the usage half, Aurora Meter is the closest thing Elixir has to a metering layer built for this. It counts on an ETS hot path, enforces plan entitlements, and reports usage to Stripe Billing Meters for you. The core is free and MIT licensed at
github.com/liamkillingback/aurora-meter, with Aurora Meter Pro adding the hosted dashboard and reconciliation.
If you would rather have the whole catalog, the Builder Pass gives you every template for one lifetime price, which is usually cheaper than the week you would spend rebuilding Cashier by hand.
Summary
There is no Laravel Cashier for Phoenix, and pretending otherwise does nobody any favours. Cashier is genuinely excellent at the first ninety percent of flat-rate subscription billing, and getting to your first charge is faster in Laravel.
What Phoenix gives you instead is a billing layer you can actually read, and a runtime that is far better suited to the part that gets hard later. Counting millions of usage events per day in ETS, reconciling webhooks with supervised retries, and streaming live usage to a customer dashboard over PubSub are all straightforward on the BEAM and awkward almost everywhere else.
Build the six pieces in this post (billable schema, Checkout, webhook reducer, query helpers, portal, meter) and you have covered what Cashier does for the vast majority of SaaS apps, in roughly 400 lines you understand completely. Then let Aurora Meter handle the metering, because that is the part worth not writing twice.