Limited time 50% off everything

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:

  1. A billable model. A trait that attaches a Stripe customer ID to your User or Team, plus subscriptions and subscription_items tables with a fixed shape.
  2. A fluent builder for subscription lifecycle. Create, swap, resume, cancel, cancel at period end, and add quantity.
  3. A webhook controller you never look at. It ships mounted, verifies signatures, and keeps the local subscription rows in sync with Stripe.
  4. 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]=true yourself 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 name column if you need it.
  • Incomplete payment recovery flows. Handle incomplete status and the invoice.payment_action_required event 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.