Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Statamic Offers Laravel Package

goldnead/statamic-offers

Statamic Offers is a Laravel package that adds offer management to Statamic CMS. Editors can create and organize offers in the control panel, while developers can surface them in Antlers templates for promotions, deals, or listings.

View on GitHub
Deep Wiki
Context7

Getting Started

This package covers two related but separable areas, and it's worth knowing which one you're in before you start:

  • The pricing/money layer — a Basket that holds lines, coupons, setup fees and bumps, plus Subscriptions::start() and Checkout::start() which actually book the payment.
  • The funnels map — since v1.18.0 it can point at offers (in addition to whatever object types it already supported). An offer entry carries type: offer, id: <offer handle> and number: <seats accepted in the period>.

The one thing to internalise before you write any UI is still that the basket total and the amount actually charged are two different numbers.

Minimal path:

  1. Install the package and publish its config/migrations as per its README, then run migrations. On v1.18.0, confirm statamic-brand-context resolves to ^1.17 — the funnels-map offer support depends on it, and if your lockfile pins an older brand context the feature will be missing or half-wired.
  2. Read the Basket class first — specifically the difference between netCent() and firstPaymentCent(). Everything else (coupons, bumps, setup fees) hangs off those two.
  3. Trace Subscriptions::start() and Checkout::start() once, end to end, to see exactly which basket components they consume. firstPaymentCent() is defined as mirroring them, so those two methods are your source of truth.
  4. First use case (money layer): the "due today" line on a checkout summary. Replace any netCent() call in that slot with firstPaymentCent() and you immediately fix the class of bug described in v1.15.0 (a paid-trial subscription showing 65 EUR while 45 EUR was booked).
  5. First use case (funnels map): point a funnel step at an offer you already sell, and verify the map resolves it by handle, not by database id. Render the number of seats the offer will accept in the period next to the offer in your funnel/landing UI — that number lives on the map entry, so it's the map (not the basket) that owns "how many we'll take".

The v1.15.0 release was additive — firstPaymentCent() is new, netCent() is unchanged — so there was no migration work, only a swap in the places where you were displaying "what will be charged now". v1.18.0 is likewise additive to the money layer; it adds a new target type to the funnels map plus a dependency floor.

Implementation Patterns

Two numbers, two labels. Treat netCent() as "order value" and firstPaymentCent() as "charged today". Keep them as separate view-model properties rather than deriving one from the other in Blade; that's how you avoid regressing back to the staging bug.

$basket = Basket::for($customer)->withLines($lines)->withCoupon($coupon);

return view('checkout.summary', [
    'dueTodayCent' => $basket->firstPaymentCent(),
    'orderTotalCent' => $basket->netCent(),
]);

Paid trials are a price replacement, not a discount. When the trial period is paid, the trial price replaces the main line price. Note the consequence: the coupon does not apply in that case, because the trial consumes the payment's single discount slot. So your summary should not render a struck-through coupon line when a paid trial is active — it will read as a lie to the customer and to support.

Order of operations for the non-trial path. Basket subtotal, minus coupon, plus setup fee, plus ticked bumps. Implement any preview/JS-side calculator against that same sequence, and add a test asserting your preview equals firstPaymentCent(); duplicating pricing logic in the frontend is the most common source of drift.

Reconcile against the booking, not against the basket. The strongest pattern is an assertion in your feature tests that the amount recorded by Subscriptions::start() / Checkout::start() equals the firstPaymentCent() you showed the user:

$shown = $basket->firstPaymentCent();
$payment = Subscriptions::start($basket, $payload);

$this->assertSame($shown, $payment->amount_cent);

That single test covers coupons, trials, setup fees and bumps in combination, and it's what would have caught the 65-vs-45 and 37-vs-27 discrepancies before staging.

Wire offers into funnels by handle, and keep the handle stable. An offer is now an addressable object in the funnels map. Add the entry with type => 'offer', id => $offer->handle, number => $seats, and treat the handle as a public identifier — the same way you'd treat a route name or a field handle in Statamic content. Everything that resolves the map (funnel previews, seat-counters, reporting) reads through that handle.

// illustrative: the shape of the entry, not the exact builder API
$map->addStep($step, [
    'type'   => 'offer',
    'id'     => $offer->handle,   // handle, NOT $offer->id
    'number' => $offer->seats_accepted_for_period,
]);

Keep "seats accepted in the period" out of the money layer. The number on an offer entry is a capacity/quota, not a price and not a basket quantity. Do not feed it into firstPaymentCent(), do not render it as a line total, and do not derive line quantity from it. If you need both on one screen (e.g. a funnel landing page with a price and a "12 seats left this month" badge), pull them from their respective owners and label them separately.

Test the map as data, not as UI. Because an offer target is just (type, id, number), you can assert the map's contents directly: that every offer handle referenced in the map resolves to a real offer, and that the numbers on offer entries match your source of truth. That's a cheap test that catches handle typos and stale entries long before a campaign does.

Use it in emails and invoices too. The "due today" figure belongs in the order confirmation and the payment receipt header; the netCent() figure belongs in the order-detail table. Mixing them across those two artefacts generates refund requests.

Gotchas and Tips

  • netCent() is unchanged, and that's deliberate. Don't "fix" it to match what's charged. If you have downstream code (reporting, exports, accounting hand-off) keyed on netCent(), verify it is genuinely meant to be order value and not due-today.
  • The coupon-silently-ignored case is the sharp edge. With a paid trial, a 10 EUR coupon produces no reduction. If your UI lets a customer enter a coupon and then shows no change, that's a support ticket. Either suppress coupon entry for paid-trial baskets or explicitly label it as applying from the second payment onward.
  • Bumps and setup fees are included, ticked bumps only. Unticked bumps must not appear in either number — check your checkbox-to-basket wiring, because a bump added to the basket but not ticked is a plausible source of off-by-N differences.
  • Debug by decomposition. When firstPaymentCent() disagrees with what got booked, print each component (main line, trial price, coupon, setup fee, each bump) individually rather than diffing totals. The two failure modes in the 1.15.0 release notes were each a single misapplied component: trial price not replacing the main line, and coupon applied where the trial had taken the discount slot.
  • Pin the behaviour with tests before you upgrade further. Because firstPaymentCent() is specified as mirroring start(), any future release that changes trial or coupon semantics can silently desync the two. A parity test is cheap insurance. The same logic applies to offers: assert map entries resolve, and assert the resolved offers are the ones you expect, so a data change in the map surfaces as a failing test rather than a broken funnel.
  • id is the offer handle, not the model id (v1.18.0). Passing a numeric id will look plausible and fail to resolve. This is the single most likely mistake when hand-writing or importing map entries.
  • Handles are now load-bearing. Renaming an offer's handle (or importing content that regenerates it) silently orphans every funnel map entry pointing at it. Treat handle changes as a deploy-time concern: grep for references, or resolve offers through a lookup that fails loudly when a handle is missing.
  • The ^1.17 brand-context floor is a hard gate (v1.18.0). If your app pins statamic-brand-context below 1.17 — common in older Statamic projects — Composer will refuse the update or you'll end up on a partial feature set. Check composer why statamic-brand-context before you plan the upgrade, and budget for upgrading that alongside.
  • Extension point worth knowing: if you add your own basket components (custom fees, gift wrapping), confirm whether they participate in firstPaymentCent() the same way setup fees do. If they don't, they'll be missing from "due today" while still being booked — the exact asymmetry the 1.15.0 work was created to fix.
  • Watch currency/rounding at the boundary. All values are integer cents, so keep arithmetic in cents and only format at the view layer; dividing to floats mid-calculation will reintroduce the discrepancy you just removed.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource