justinholtweb / craft-fjord
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
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
- craftcms/commerce: ^5.0.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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()setspaymentSourceId = nullwhen an order completes — deliberately, so a completed order cannot be silently re-charged — and Commerce reaches it fromTransactions::saveTransaction(), which runs insidePayments::processPayment()ahead ofEVENT_AFTER_PROCESS_PAYMENT. Capture onEVENT_BEFORE_PROCESS_PAYMENTor there is no such thing as a one-click upsell. Order::setPaymentSource()nullsgatewayId, 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
invalidCouponRemovednotice, 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.
Purchasablesonly resolves by ID, but every purchasable has a row incommerce_purchasablescarrying 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.