The Checkout, End to End
Checkout is three service calls against one quote; a failure before placeOrder finishes leaves the cart active and usable.
$orderId = $cartManagement->placeOrder((int) $cartId, $paymentMethod);Checkout is a short sequence of service calls against one quote. Knowing which call writes what is what makes a broken checkout diagnosable instead of mysterious.
The three calls
Add to cart: POST /V1/carts/mine/items, handled by Magento\Quote\Api\CartItemRepositoryInterface::save(). Writes quote_item and recollects totals. On the storefront the checkout/cart/add controller does the same work.
Shipping step: POST /V1/carts/mine/shipping-information, handled by Magento\Checkout\Api\ShippingInformationManagementInterface::saveAddressInformation(). Writes both rows in quote_address plus the chosen carrier and method codes, recollects totals, and returns the available payment methods and totals in one response.
Place order: POST /V1/carts/mine/payment-information, handled by Magento\Checkout\Api\PaymentInformationManagementInterface::savePaymentInformationAndPlaceOrder(). Writes quote_payment, then calls Magento\Quote\Api\CartManagementInterface::placeOrder(), which returns the order id.
Inside placeOrder
In 2.4.9 placeOrder() runs inside a quote-level lock, Magento\Quote\Model\CartMutexInterface, so two rapid clicks cannot produce two orders. It dispatches checkout_submit_before, converts the quote to an order, dispatches sales_model_service_quote_submit_before, and calls OrderManagementInterface::place(), which is where the payment method's authorize or capture command runs. Only after that succeeds does it set is_active to 0 and save the quote. checkout_submit_all_after fires last.
Where a failure leaves the quote
If placement throws, the quote is never deactivated. The shopper still has their cart, with the payment data on it, and sales_model_service_quote_submit_failure is dispatched with the quote and the exception. A declined card therefore leaves no usable order and a recoverable cart.
Example
<?php
// app/code/Vendor/CheckoutLog/Observer/LogSubmitFailure.php
declare(strict_types=1);
namespace Vendor\CheckoutLog\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Psr\Log\LoggerInterface;
class LogSubmitFailure implements ObserverInterface
{
public function __construct(
private readonly LoggerInterface $logger
) {
}
public function execute(Observer $observer): void
{
// Shorthand: the magic getter on the event.
$quote = $observer->getEvent()->getQuote();
// Longhand, the same read:
// $quote = $observer->getEvent()->getData('quote');
$exception = $observer->getEvent()->getData('exception');
$this->logger->error(
sprintf(
'Order placement failed for quote %d (payment %s): %s',
(int) $quote->getId(),
(string) $quote->getPayment()->getMethod(),
$exception->getMessage()
),
['quote_active' => (int) $quote->getIsActive()]
);
}
}
// var/log/system.log
// [2026-10-05T09:14:02.112+00:00] main.ERROR: Order placement failed for quote 142
// (payment vendor_pay): Card declined by issuer. {"quote_active":1} []
When to use it
- Find out whether a missing order failed at the gateway or never reached placeOrder.
- Confirm that a declined payment left the shopper's cart intact.
- Reproduce a customer's checkout from the command line against the same quote.
- Add a field that must be saved at the shipping step rather than at order placement.
More examples
Registering the observer
Placing this file in etc/ rather than etc/frontend/ means the observers also fire for admin orders and REST calls, which is what you want for an audit trail. These two events bracket order placement: the failure event carries order, quote and exception, and checkout_submit_all_after carries order and quote. Run bin/magento cache:flush after adding the file, since event configuration is cached.
<!-- app/code/Vendor/CheckoutLog/etc/events.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="sales_model_service_quote_submit_failure">
<observer name="vendor_checkoutlog_submit_failure"
instance="Vendor\CheckoutLog\Observer\LogSubmitFailure"/>
</event>
<event name="checkout_submit_all_after">
<observer name="vendor_checkoutlog_submit_success"
instance="Vendor\CheckoutLog\Observer\LogSubmitSuccess"/>
</event>
</config>
The same flow over REST
Walking the sequence with curl isolates which step fails, because each call writes a different part of the quote. If step 3 succeeds but step 4 returns a 400, the quote still has its items, address and method, so you can retry the last call alone. Swap the mine endpoints for guest-carts and the masked id to reproduce a guest checkout, which is the only way to test code paths that depend on there being no customer id.
# 1. A logged-in customer token, then an empty cart.
TOKEN=$(curl -s -X POST "$BASE/rest/V1/integration/customer/token" \
-H 'Content-Type: application/json' \
-d '{"username":"[email protected]","password":"..."}' | tr -d '"')
AUTH="Authorization: Bearer $TOKEN"
# 2. Add an item -> writes quote_item, recollects totals.
curl -s -X POST "$BASE/rest/V1/carts/mine/items" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"cartItem":{"sku":"24-MB01","qty":1}}'
# 3. Shipping step -> writes quote_address, saves the method,
# and returns payment_methods and totals in one response.
curl -s -X POST "$BASE/rest/V1/carts/mine/shipping-information" -H "$AUTH" \
-H 'Content-Type: application/json' -d @shipping-information.json
# 4. Place the order -> writes quote_payment, then placeOrder(). Prints the order id.
curl -s -X POST "$BASE/rest/V1/carts/mine/payment-information" -H "$AUTH" \
-H 'Content-Type: application/json' \
-d '{"paymentMethod":{"method":"checkmo"}}'
# A guest runs the same sequence against /V1/guest-carts/<masked id>/...Placing an order server side
This is the one call that turns a quote into an order, and it is the same entry point the storefront, the REST endpoint and an admin order all reach eventually. The lock inside placeOrder() is per quote id, so a second concurrent call for the same cart waits or fails rather than creating a duplicate order. Reach for this when rebuilding abandoned carts or writing an integration test that needs a real order with real totals.
<?php
// app/code/Vendor/CheckoutLog/Model/PlaceFromQuote.php
declare(strict_types=1);
namespace Vendor\CheckoutLog\Model;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Quote\Api\CartManagementInterface;
use Magento\Quote\Api\Data\PaymentInterfaceFactory;
class PlaceFromQuote
{
public function __construct(
private readonly CartManagementInterface $cartManagement,
private readonly PaymentInterfaceFactory $paymentFactory
) {
}
public function execute(int $cartId, string $methodCode): int
{
$payment = $this->paymentFactory->create();
$payment->setMethod($methodCode);
try {
return (int) $this->cartManagement->placeOrder($cartId, $payment);
} catch (CouldNotSaveException $e) {
// The quote is still active here: the cart survives a failed placement.
throw $e;
}
}
}
Discussion