Skip to main content

Cart and checkout

A shopper's cart is calculated again on every request that shows it. When the shopper places the order, the cart is turned into an order as one unit and then deleted. This page explains the classes that do this, and says for each whether an extension may use it.

The events of the cart and the checkout are on Order and cart events. The order of the events is on Event flows. This page does not repeat them.

What it does​

ClassIn plain wordsFor extensions?
Cart (site/src/Service/Cart/Cart.php)The cart as a plugin reads itRead only. Events hand it to you.
CartLine (site/src/Service/Cart/CartLine.php)One line of that cartRead only
CartContributionBuilder (administrator/src/Service/Cart/Plugin/)Describes one charge a plugin adds to the cartYes, through onCartTotals. You never create it yourself.
CartService (site/src/Service/Cart/CartService.php)What a page or a module may ask of a cart: add, change, readYes, for a module or a page of your own. Not from inside a cart event.
CartSnapshot, CartTotalsViewWhat CartService answers: the cart at one moment, with its totalsRead only
CartTotalsAdds the cart upNo, core's own
CartRepository (administrator/src/Service/Cart/CartRepository.php)Every write to the cart tablesNo. Do not call it.
CartRulesEngine, CartRules (site/src/Service/CartRules)Work out which cart rules applyNo, core's own
OrderPlacement (site/src/Service/Checkout/OrderPlacement.php)Turns a cart into an orderNo, core's own. You take part through its events.
PlacementCart, PlacementCommand, PlacementOutcomeThe cart, the request and the result of one placementNo, core's own

When it is used​

  • Your payment or shipping plugin is handed a cart and you want to know what you can read from it.
  • Your feature plugin adds a charge to the cart.
  • Your module shows the cart.
  • You want to know what is already saved when your plugin is called during a placement.

The cart reader​

What it does​

Cart is the cart as it stood when the event fired. It has no method that changes anything. Reading it three times gives the same answer three times.

When it is used​

These events hand it to you with getCart():

EventWho receives itPage
onCartTotalsEach admitted plugin of the alfa groupOrder and cart events
onCalculateShippingCostThe shipping plugin of a methodShipping plugins
onCartViewThe payment or shipping plugin of an offered methodPayment plugins, Shipping plugins

What you are given​

MethodReturnsMeaning
id()intThe cart's id. 0 when the cart has no row yet.
isEmpty()boolNothing is in it
itemCount()intHow many lines
quantity()intHow many units over all lines
isVirtual()boolNothing in the cart needs shipping. Read from the items' own "virtual" setting, never guessed from a weight of zero.
lines()CartLine[]The lines, in cart order
currency()CurrencyThe currency the cart is priced in
customer()UserWhose cart it is
shipmentId()intThe chosen shipping method, 0 for none
paymentId()intThe chosen payment method, 0 for none
delivery()AddressViewThe delivery address, read by field
invoice()AddressViewThe invoice address, read the same way
getSubtotal()MoneyThe sum of the line prices, tax included
getTotal()MoneyWhat the shopper pays in total
state()For Alfa. Do not call it.

getTotal() throws a LogicException in onCartTotals and in onCalculateShippingCost, because the charges and the shipping cost are what is being decided there. Use getSubtotal() in those two. In onCartView the total exists and getTotal() answers.

A cart line (CartLine):

MethodReturnsMeaning
id()intThe cart line's id
itemId()intThe item on the line
combinationId()?intThe chosen combination, or null for an item without one
quantity()intHow many units
name()stringThe item's name in the active language
width(), height(), depth(), weight()floatThe measurements of one unit: the item's own, replaced by the combination's where the combination sets them. Plain numbers in the shop's units.
isVirtual()boolNothing here to carry
isUnavailable()boolThe item has left the catalogue. The line stays, and nothing may be priced against it.

The address reader (AddressView) gives the checkout fields. The examples in the class comment are:

$postcode = $cart->delivery()->field($postcodeFieldId)->value();
$placeId = $cart->delivery()->role('country')->int();

$postcodeFieldId is the id of a checkout field that your method's settings name. Alfa has no postcode field of its own.

What you are expected to do​

Read. To change a cart, a page or a module calls CartService (below). A handler of a cart event must not change the cart it is being asked about.

The charge builder​

$event->contribution('group.element', $code) in onCartTotals returns a CartContributionBuilder. Its methods and a full sample are on Order and cart events.

What it guaranteesWhat it refuses
A charge that reaches the totals is complete: it has a label and either an amount or a percentageA blank code, an amount of 0 or less, a percentage of 0 or less or above 100, a tax rate outside 0 to 100, and amount() together with percent(). Each throws at the call.
The charge is stored with the order when the order is placedA charge without a label, or without amount() or percent(). It is dropped when Alfa collects the charges.

CartService​

What it does​

CartService is the one class a page, a controller or a module asks when it wants something of a cart. Each method is one thing a person does: add an item, change a quantity, choose a method, look. It answers with a result and a reading, never with the cart itself.

You get one with a static method:

MethodWhose cart
CartService::ambient(): selfThe current visitor's own cart
CartService::forSubject(CartSubject $subject): selfA cart that one person operates for another. Alfa's back office uses it when staff work a customer's cart.

When it is used​

Alfa's own cart controller and cart page use it, and so does the cart module that ships with Alfa (modules/mod_alfa_cart). Use it the same way in a module or a page of your own.

Do not call it from a handler of onCartTotals, onCalculateShippingCost or onCartView. Those run while the cart is being calculated. This is this manual's advice: no check in the code refuses such a call, and what it would do is not documented.

CartService checks no user right. ambient() always works on the current visitor's own cart.

Reading the cart in a module​

This is how the shipped cart module reads the cart (modules/mod_alfa_cart/src/Dispatcher/Dispatcher.php):

use Alfa\Component\Alfa\Administrator\Service\Cart\CartResolution;
use Alfa\Component\Alfa\Site\Service\Cart\CartService;

$snapshot = CartService::ambient()->read(CartResolution::Current);

if (!$snapshot->isEmpty()) {
$units = $snapshot->quantity; // int
$lines = $snapshot->itemCount; // int
$total = $snapshot->totals->grand->format(); // text, with the currency symbol
}

CartResolution::Current means "look only": a visitor who has no cart still has none afterwards.

The methods​

Every method below is public. Looks never create a cart. Changes may create one where the table says so.

MethodWhat it doesWhat it guaranteesWhat it refuses
read(CartResolution $mode, int $cartId = 0): CartSnapshotReads a cartCreates no cart. It is not free of writes: it adopts a guest's cart after sign-in, and lowers a quantity that is no longer in stock.CartResolution::Mutable throws InvalidArgumentException
add(int $itemId, int|float $quantity): CartAddResultPuts an item in the cart. Reads the chosen field values and uploads from the current request.Creates a cart for a first-time visitor. Its comment calls it the only method that may; in the code chooseShipment() and choosePayment() open the cart the same way, and createNamedCart() always makes one. The result carries the line as the cart now prices it.A request it cannot read, or a write that fails: added is false and the shopper gets one sentence
setLineQuantity(int $lineId, int $quantity): CartMutationResultSets how many of one lineThe quantity is held to stock and to the item's own rules. The line is removed when it reaches 0.A line of another cart
setLineQuantityAtCheckout(int $lineId, int $quantity, ?int $placeId): CartMutationResultThe same at checkout, and then checks the chosen methods against what is still offeredA reading only when the change was writtenThe same
clear(): boolTakes everything out and keeps the cartReturns false only for a storage failure
applyCoupon(string $code): stringEnters a coupon codeReturns one of the CouponOutcome constants, which says what happenedA code is kept on the cart only when it applied, or when its rule is real and its conditions are not met yet. Any other code is refused and not kept.
removeCoupon(string $code): voidTakes a code offReports nothing. A storage failure is logged.
pointAtSavedDetails(int $deliveryId, ?int $invoiceId = null): voidPoints the cart at a saved addressReports nothing. A storage failure is logged.
chooseShipment(int $shipmentId, ?int $placeId): CartMutationResultChooses the shipping methodThe delivery place is applied first, because it decides which methods are offered. May create a cart for a first-time visitor.A choice that could not be written: succeeded is false and no reading is taken
choosePayment(int $paymentId, ?int $placeId): CartMutationResultChooses the payment methodThe same order of stepsThe same
checkout(?int $placeId): CartSnapshotThe checkout as it stands, with the offered methodsCreates no cart and sets no cookie
openCheckout(): CheckoutCartThe checkout page's look: first whether the cart is empty, then the readingCreates no cart
lineConfiguration(object $item, int $lineId): ?arrayWhat the shopper chose on one line, so that the item page can open it againnull for a line that is not the visitor's
createNamedCart(): NamedCartEditorA new cart for the subject's customer. For the back office.Always a new cart, never one that already exists
editCart(int $cartId): NamedCartEditorA handle on a cart known by its id. For the back office.

What you are given back​

CartSnapshot is the cart at one moment. All of its properties are read only.

PropertyTypeMeaning
cartIdintThe cart, or 0 for none
linesobject[]The lines as the cart page reads them
itemCount, quantityintLines, and units over all lines
isVirtualboolNothing needs shipping
currencyCurrencyThe cart's currency
totalsCartTotalsViewWhat it costs, see below
rulesCartRulesViewThe cart rules applied (applied), the codes held (codes), rules the shopper is close to (progress), and their sum (total)
offersCartOffersViewThe payment and shipping methods offered, and the chosen ones
customerUserWhose cart it is
placeId?intThe delivery place of this request

Its methods are isEmpty(), exists() and, since 2.2.0, shippingAwaitsChoice(): the cart needs shipping and no shipping method is chosen yet, so the shipping cost is not known. It is not zero.

CartTotalsView, each amount a Money object:

PropertyMeaning
goods, goodsExclThe line totals, with and without tax
beforeDiscountWhat the lines came to before catalogue discounts
taxThe tax of the lines
discountWhat the catalogue discounts took off
shipmentThe shipping cost as the cart pays it, after shipping rules
shipmentGrossThe shipping cost before shipping rules
shippingBenefitThe shipping rule's own summary, as an object. Its properties are not documented here.
contributionsThe charges plugins added, as a list
grand, grandExclWhat the shopper pays, with and without tax
contributionsNotLoadedSince 2.2.0. The plugins whose charge is missing because Joomla did not load them for the current user. See When staff operate a customer's cart.

CartMutationResult has succeeded, cartId and snapshot (which is null when no reading was taken). CartAddResult has added, lineId, line, itemCount, quantity and grandTotal.

How the cart is added up​

CartTotals holds the sum. An extension does not call it; the figures reach you in CartTotalsView.

grand total = line totals + shipping after shipping rules + plugin charges - cart rules

Changed in 2.2.0. The old helper page said "items plus shipping". Plugin charges and cart rules are part of the total.

CartRepository​

Core's own. It is the single class that writes the cart tables, and it knows nothing of a request, a session or a user. It throws CartPersistenceException and leaves the wording for the shopper to its caller.

Do not call it and do not write the cart tables. How many units a cart may hold is decided above it, by the classes CartService uses, so a quantity written straight through the repository is not held to stock or to the item's own limits.

Cart rules​

A cart rule is a promotion applied in the cart, with or without a code. CartRulesEngine receives the cart's lines as plain data and returns the rules that apply. It is core's own.

What an extension author needs to know:

FactMeaning for you
There are three classes of rule: item, order and shipping, applied in that orderAn item or order rule lowers the goods only. A shipping rule lowers the shipping cost only.
Within a class the rule that gives the shopper most wins. Others join only when both rules allow combining.You cannot predict the discount from one rule alone
Amounts are tax included
The rules used are stored with the order when it is placed (table #__alfa_order_cart_rule)An order keeps its discounts whatever happens to the rule later
How often a rule was used is counted from those stored rowsThere is no counter to update

A plugin cannot add a cart rule or lower a total through an event. A charge added in onCartTotals only adds.

Placing an order​

What it does​

OrderPlacement turns one cart into one order. It checks, asks the plugins that may refuse, saves the order as one unit, and then lets the payment and shipping plugins write their rows.

It is core's own. Alfa calls it from the storefront's cart controller, and from the back office when staff place an order for a customer. An extension takes part through the events in the picture, and never calls it.

What happens​

Event flows shows the same placement in two pictures, with the stock events drawn one by one.

The steps, and what each guarantees​

Step in the pictureWhat is true
2 to 4Nothing is written. A refusal costs one failed checkout and leaves no order.
5The cart's row is locked first. A second request with the same cart (a double click, a browser retry) waits here.
5The order and its lines are written by OrderCreator, the same class every other source of orders uses. See Orders.
6If the stock cannot be set aside, the placement fails and everything is undone.
7The plugin charges and the cart rules used are stored with the order. If one of them cannot be written, the placement fails: an order is never saved with money rows that disagree with its total.
7Two steps are recorded as background work: download access and the confirmation e-mail.
7The cart is deleted inside the save. Cart and order never exist together.
The lineThe save is final (the commit, OrderPlacement.php line 528). The order exists, and nothing after this line can make that untrue.
9The payment plugin and the shipping plugin each write their row. Each row is a save of its own.
10The two recorded steps run after both plugins are done, so the confirmation e-mail shows the order with its payment and its parcel.
11onAlfaOrderPlaced is announced last, so a listener sees the order as the shopper completed it.

What happens to the cart​

The cart is deleted in step 7, before the save is final, and for two reasons.

  1. No double order. The second of two requests for the same cart takes the lock after the first has finished, finds the cart gone and is refused as an empty cart. This is why an order holds no reference to the cart it came from.
  2. No in-between state. If the save is undone, the cart is back with it.

So in every event after the line (onOrderAfterPlace, onAlfaOrderPlaced) there is no cart any more. Read the order.

What a failure after the save leaves​

The save is final at one point, and what happens after it cannot undo it.

What failsWhat is leftWhat the person sees
Anything before the lineNo order. Nothing was written. The cart is as it was.The reason, as a message
Reading the saved order backThe order, without a payment row and without a parcel. onAlfaOrderPlaced is not announced.A warning that part of the order could not be set up
The payment plugin in step 9The order without a payment row. The shipping plugin is still called.The same warning, naming what failed
The shipping plugin in step 9The order without a parcelThe same warning
Customer credit could not be usedThe order. The payment plugin is asked to collect the full total.A warning that the credit was not used
A listener of onAlfaOrderPlacedThe order, complete. The error is logged and the next listener runs.Nothing
Any other error after the lineThe orderA warning. The placement still answers "placed".

The recorded steps of step 10 are not lost when the request stops: Alfa carries them out later.

How Alfa's own callers read the result​

This is for understanding the flow. OrderPlacement and PlacementOutcome are core's own.

Method of OrderPlacementMeaning
place(PlacementCommand $command): boolPlaces the order. true when the order exists.
placeOrder(array $data, array $billing = []): boolThe same for the storefront: reads the chosen methods from the request and calls place()
getOutcome(): PlacementOutcomeWhat happened, see below
isCommitted(): bool, getCommittedOrderId(): intWhether the order exists, and its id
getWarnings(): array, getFailures(): arrayWhat did not finish after the save, and why a placement was refused, each as a language key with its values
getOrder(): ?objectThe saved order as it was read back. It can be null for an order that exists.

PlacementOutcome has three states: REJECTED (no order), COMMITTED (the order exists and everything after the save finished) and COMMITTED_WITH_WARNINGS (the order exists, something after the save did not finish). Its methods are isCommitted(), hasWarnings(), orderId(), state(), failures() and warnings().

PlacementCart is the cart as the placement needs it: its lines, its money, its rules, its charges, the offered methods, and discard(). Its class comment says it is "not a plugin API and not the application API".

What you must provide​

You are writingWhat you provide
A plugin that reads the cartA handler for one of the events above. Nothing else.
A plugin that adds a chargeA handler of onCartTotals, declared in getTargetedHandlers()
A module that shows the cartA call to CartService::ambient()->read(CartResolution::Current). Load the language file com_alfa if your module draws Alfa's own layouts, as the shipped module does.

What can go wrong​

What you seeThe usual cause
A LogicException "The final cart total is not available at this stage"getTotal() was called in onCartTotals or onCalculateShippingCost. Use getSubtotal().
A cart row appears for every visitorA module read the cart with a method that may create one. Use read() with CartResolution::Current.
read() throws InvalidArgumentExceptionIt was called with CartResolution::Mutable
Your plugin is called in onOrderAfterPlace and finds no cartThe cart is deleted before the save is final. Read the order.
An order exists but has no payment rowThe payment plugin failed after the save was final. See the table above.

Changed in 2.2.0​

  • Changed in 2.2.0. CartHelper and OrderPlaceHelper no longer exist. CartService and OrderPlacement replaced them, with other method names.
  • Changed in 2.2.0. The cart is deleted inside the save of the order, not after the "after place" events.
  • Changed in 2.2.0. A placement that fails after the save is final reports "placed, with warnings". It no longer reports a failure for an order that exists.
  • Changed in 2.2.0. Placing an order does not write the stock column of an item. Stock is set aside through the stock service. See Stock events.
  • Since 2.2.0. CartSnapshot::shippingAwaitsChoice() and CartTotalsView::contributionsNotLoaded.

Not documented yet​

  • The address reader (AddressView) in full.
  • The properties of the line objects in CartSnapshot::lines and of the method lists in CartOffersView.
  • NamedCartEditor and CartSubject, which the back office uses to work a customer's cart.
  • The CouponOutcome values one by one.
  • Whether a plugin may call CartService to change a cart outside the cart events, for example from a handler of its own page. The class comment of Cart says a plugin that must change a cart goes through CartService; no shipped plugin does it.