Search by

justinholtweb / craft-fjord

justinholtweb

Sales funnels for Craft Commerce — multi-step cart and checkout flows with order bumps, one-click post-purchase upsells, A/B split testing and per-step analytics.

Package info

github.com/justinholtweb/craft-fjord

Type:craft-plugin

pkg:composer/justinholtweb/craft-fjord

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

5.0.0 2026-08-23 14:37 UTC

This package is auto-updated.

Last update: 2026-08-26 23:44:51 UTC


README

Sales funnels for Craft Commerce 5 — multi-step cart and checkout flows with order bumps, one-click post-purchase upsells, A/B split testing and per-step analytics.

The Craft answer to WordPress's CartFlows: a store defines flows made of ordered steps, and Fjord owns the routing between them, the customer's state as they move, the offers attached to each step, the split test on any of them, and the numbers that come out the other end.

Lite is free. Pro is $99.

What it does

A flow is a base URI and a list of steps. Each step is a page:

Step What it is
Landing An ordinary page at the front of the funnel
Opt-in Collects an email onto the cart before the checkout
Checkout Stocks the cart with the products this funnel sells, and shows any order bumps
Upsell A post-purchase offer, charged with one click against the card already used
Downsell The offer a customer sees after turning the first one down
Thank You The end of the funnel

Fjord decides which of those a visitor may see, in what order, and what to do when they finish one. It does not decide what they look like: steps render your templates, with your markup, the way Commerce checkout already works.

Installation

composer require justinholtweb/craft-fjord
php craft plugin/install fjord

Then copy the reference templates as a starting point:

php craft fjord/flows/install-templates

They land under the configured Template Root (shop/fjord by default). They are a starting point, not a runtime dependency — Fjord renders whatever is at the path and never falls back to its own copy, because a funnel silently rendering a plugin's placeholder is worse than one that tells you which template is missing.

The two invariants

Two rules keep the plugin honest, and everything else is arranged around them.

services\Router::resolve() is the only place a request becomes a step. The site route, craft.fjord.next, the redirect after checkout, the offer endpoints and the control panel all read the same Resolution. Guarding — you cannot reach a thank-you page without an order, or an upsell without a paid one — and split-test assignment live there and nowhere else, so a preview can never disagree with what a customer gets.

services\Offers::accept() is the only place an offer becomes money. A bump ticked at checkout, a one-click upsell after purchase and the console all take the same path. It is idempotent on (orderId, offerId) because of a unique index, not a check-then-act: a double-submitted upsell collides on the index instead of charging twice.

Templates

Fjord passes every step template flow, step, variant, session, cart, order, offer, bumps and nextUrl, and craft.fjord is available everywhere:

{{ craft.fjord.session }}              {# the visitor's place in the funnel #}
{{ craft.fjord.step }}                 {# the step they are on #}
{{ craft.fjord.next(step) }}           {# where "continue" goes #}
{{ craft.fjord.continueUrl }}          {# where a checkout's `redirect` should point #}
{{ craft.fjord.bumps(step, cart) }}    {# the order bumps for this checkout #}
{{ craft.fjord.isOnOrder(offer, cart) }}
{{ craft.fjord.grants(order) }}        {# what each offer resolved to #}
{{ craft.fjord.storeCheckoutUrl }}     {# null when no flow has taken over the checkout #}
{{ craft.fjord.isPro }}

The one thing to get right in a checkout template

Point Commerce's redirect at craft.fjord.continueUrl, not at a page of your choosing:

{{ redirectInput(craft.fjord.continueUrl) }}

The next step is decided after the order completes. At render time there is no completed order, so every post-purchase step looks unreachable and any URL baked into the form would skip the upsell.

Front-end endpoints

Action Does
fjord/checkout/toggle-bump Ticks or unticks an order bump (offer, on)
fjord/checkout/accept-offer Takes a post-purchase offer with one click (step)
fjord/checkout/decline-offer Refuses it and moves on (step)
fjord/checkout/next Moves on from a step (step)
fjord/checkout/store-checkout Sends a shopper into the store-checkout flow
fjord/flow/continue Resolves where the visitor goes next, when they get there

Each answers JSON to an Ajax caller and a redirect to a plain form post, so templates can be written either way.

One-click upsells

An accepted post-purchase offer is charged on a new order linked back to the original, not added to it. A completed Commerce order is locked at RECALCULATION_MODE_NONE, and its totals have already been emailed, invoiced and possibly exported — adding a line to one after the fact rewrites a document the customer already has. A linked order is refundable on its own and shows up in Commerce's own screens without anything having to know about Fjord.

For this to work at all, the customer's payment method has to be reusable, which means:

  • the gateway must support payment sources and purchases; and
  • the customer must have saved the card (savePaymentSource) at checkout.

Fjord captures the gateway and payment source before the payment, on Payments::EVENT_BEFORE_PROCESS_PAYMENT — see Notes below for why after is too late. When there is nothing reusable, every post-purchase step is skipped rather than shown and refused.

Three things bound what can be charged, and all three are checked again server-side when the accept endpoint is called, so a hand-crafted POST cannot get past them:

  • the parent order must be paid (configurable);
  • the offer window must still be open (60 minutes by default), so a tab left open overnight cannot bill someone the next morning; and
  • the offer must not already be resolved for that order.

Order bumps

A ticked bump adds a line item stamped with options.fjordOffer, and the discount is applied by an order adjuster reading that stamp. The line item is the truth, not the grant ledger, so the discount survives a recalculation, a control-panel edit and anything else that touches the cart without going through Fjord.

The adjuster is spliced in ahead of the tax adjuster. Commerce runs shipping → discounts → tax and EVENT_REGISTER_ORDER_ADJUSTERS appends, so an adjuster that arrives after tax charges the customer tax on a discount they were given.

Raising a bumped line's quantity by hand does not extend the bump price to the extras: the offer's own quantity is the discounted quantity.

Split testing

A step can have variants with traffic weights. Whatever the weights leave over goes to the step's own template — the control — so a test always has one. A visitor is assigned once and keeps that arm for the life of their session.

Fjord reports significance (a two-proportion z-score against the control) and never acts on it. A plugin that silently reweighted traffic on a number that thin would be worse than one that reports it and lets a person decide.

Reports

Per step: visitors, views, how many moved on, drop-off, and — on Pro — orders, revenue and offer take rate. Per flow: sessions, orders, end-to-end conversion, AOV, and what share of revenue came from bumps and post-purchase offers rather than from the thing the funnel set out to sell.

A step converts when a visitor leaves it forwards, recorded when the next step is seen. That is the only definition that does not require guessing at intent. Rates are per session, not per view: a reload is not another chance to convert.

Console

php craft fjord/flows                     # the flows and their steps
php craft fjord/flows/routes              # the site routes Fjord contributes
php craft fjord/flows/check               # preflight: missing templates, dead SKUs, no capable gateway
php craft fjord/flows/export --file=…     # the whole configuration as JSON
php craft fjord/flows/import --file=…     # read it back; same-handle flows are replaced
php craft fjord/flows/install-templates   # copy the reference templates into your site
php craft fjord/stats/report [handle]     # step-by-step performance
php craft fjord/stats/prune               # expired sessions and events past retention

Configuration lives in the database rather than project config — the same call Commerce makes for its own shipping methods — because flows reference purchasable IDs, entry IDs and coupon codes, which are environment-specific. export/import is how it moves, and purchasables travel by SKU as well as by ID so a product does not silently become a different product.

Editions

Lite — flows and steps, routing, sessions and step guarding, checkout steps that stock the cart, the store-checkout override, the Twig variable and front-end actions, per-step view and conversion counts, console commands, export and import.

Pro ($99) — order bumps, upsell and downsell steps with the one-click post-purchase charge, A/B split testing, condition-builder routing rules, auto-applied coupons, revenue analytics and webhooks.

Pro configuration that survives a downgrade is ignored, not obeyed: a Lite install keeps the rows, serves none of them, and says so in the control panel.

Webhooks (Pro)

Subscribe an endpoint to step.view, step.convert, order.complete, offer.accepted, offer.declined or offer.failed. Deliveries are queued, never inline — an upsell must not get slower, or fail, because a marketing endpoint is down. Every delivery carries X-Fjord-Signature: an HMAC-SHA256 of the exact body sent, keyed with the webhook's secret.

Notes

A few things about Commerce that this plugin is shaped by, recorded here because they are not obvious and cost real time:

  • The payment source is gone before any after-payment event fires. Order::updateOrderPaidInformation() sets paymentSourceId = null when an order completes — deliberately, so a completed order cannot be silently re-charged — and Commerce reaches it from Transactions::saveTransaction(), which runs inside Payments::processPayment() ahead of EVENT_AFTER_PROCESS_PAYMENT. Capture on EVENT_BEFORE_PROCESS_PAYMENT or there is no such thing as a one-click upsell.
  • Order::setPaymentSource() nulls gatewayId, so the gateway has to come from the source rather than the order.
  • An order's customer is resolved from its email, so setting an unrelated address after assigning a customer quietly reassigns the order to somebody else.
  • Commerce's coupon validator does not fail on a coupon that does not apply — it deletes the code, adds an invalidCouponRemoved notice, and reports the order valid. Validation passing proves nothing; the only honest test is whether the code is still there afterwards.
  • Commerce 5 has no SKU lookup. Purchasables only resolves by ID, but every purchasable has a row in commerce_purchasables carrying its SKU, which is what makes a SKU usable as a portable reference.

Testing

cd ~/Sites/plugin-testing
ddev exec php /var/www/craft-fjord/tests/integration/checks.php   # 125 checks

Idempotent and self-cleaning. The suite takes a real payment through Commerce's dummy gateway, so the one-click charge is exercised for real rather than mocked; Lite is exercised in its own section and the original edition is restored afterwards.

Requirements

Craft CMS 5.3+, Craft Commerce 5.0+, PHP 8.2+.

License

Proprietary. See LICENSE.md.