Extending Checkout Validation in Magento 2
AI generated
M2
di.xml
Magento 2 · Checkout · Validation · Quote
Checkout validation in Magento 2
extended precisely and safely

Native checkout validation in Magento 2 checks required fields and obvious format errors, but no business specific rules such as minimum order values per customer group, restricted shipping countries for certain products, or payment data consistency. Custom checkout validation belongs cleanly in service contracts and plugins, not in fragile frontend checks alone.

18 min read QuoteValidator · CartManagement · PaymentInformationManagement Magento 2.4.8-p4 · PHP 8.4

1. Why native checkout validation often falls short

Out of the box, Magento checks in the checkout whether required fields are filled in, whether an email address looks syntactically correct, and whether a postal code matches the selected country. This native checkout validation reliably covers technical format errors, but knows nothing of business specific rules. A minimum order value of fifty euros for a certain customer group, a shipping exclusion for hazardous goods to certain countries, or a required VAT number for company customers are requirements that go beyond generic form validation.

Anyone who only checks such rules in the frontend via JavaScript builds in a gap that is easy to bypass: a direct API call against CartManagementInterface::placeOrder ignores any client side check completely. Real checkout validation must therefore always be anchored server side, in service contracts and plugins that apply regardless of the frontend used, whether Hyvä checkout, a mobile app, or direct REST access.

This article shows how custom validation rules for addresses, cart and payment data are hooked cleanly into existing checkout validation, what role client side checks in the Hyvä frontend may still play, and how race conditions on concurrent order placement are avoided. The focus is on Magento 2.4.8-p4 with PHP 8.4 and constructor property promotion.

2. The three validation layers in the checkout

Robust checkout validation consists of three layers that serve different purposes. The first layer is the client side check in the browser, which gives immediate feedback but must never serve as the sole safeguard. The second layer consists of plugins on the web API service contracts such as CartManagementInterface and PaymentInformationManagementInterface, which intercept every request regardless of the frontend. The third layer is validation directly on the domain model, for example in the QuoteValidator or in observers reacting to events such as sales_model_service_quote_submit_before.

These three layers complement each other, but do not replace one another. A checkout validation that only exists at the frontend layer can be bypassed. A validation that only sits on the domain model gives feedback to the customer late and unspecifically. The most robust combination checks the same rule client side for quick feedback and server side for actual enforcement, where the server side check always remains the authoritative instance.

For Hyvä projects, client side checks additionally run through Alpine.js and Magewire, not through KnockoutJS validators. The fundamental three layer architecture remains unaffected, only the concrete implementation of the frontend layer changes.

3. Implementing custom address validation via a plugin

A common use case for custom checkout validation is checking whether a shipping address may even be delivered to at all, for example because a product in the cart may not legally be shipped to certain countries or regions. This check does not belong in address validation itself, it belongs in a plugin on CartManagementInterface::placeOrder that runs before the actual order creation and throws a speaking exception on a rule violation.

The following plugin checks whether the shipping address is located in a country restricted for at least one product in the cart. The actual restriction list comes from a dedicated service that matches product attributes against a configurable country list, so the rule stays maintainable in the admin area instead of being hardcoded.


<?php

declare(strict_types=1);

namespace Mironsoft\CheckoutValidation\Plugin;

use Magento\Framework\Exception\LocalizedException;
use Magento\Quote\Api\CartManagementInterface;
use Magento\Quote\Api\CartRepositoryInterface;
use Mironsoft\CheckoutValidation\Api\ShippingRestrictionCheckerInterface;

/**
 * Blocks order placement when the shipping address is located in a country
 * that is restricted for at least one product currently in the cart.
 */
class ValidateShippingRestrictionsPlugin
{
    /**
     * @param CartRepositoryInterface $cartRepository
     * @param ShippingRestrictionCheckerInterface $restrictionChecker
     */
    public function __construct(
        private readonly CartRepositoryInterface $cartRepository,
        private readonly ShippingRestrictionCheckerInterface $restrictionChecker
    ) {
    }

    /**
     * Runs before the native order placement and aborts with a clear
     * error message if a restricted shipping destination is detected.
     *
     * @param CartManagementInterface $subject
     * @param int $cartId
     * @param mixed $paymentMethod
     * @return void
     * @throws LocalizedException
     */
    public function beforePlaceOrder(
        CartManagementInterface $subject,
        int $cartId,
        $paymentMethod = null
    ): void {
        $quote = $this->cartRepository->get($cartId);
        $countryId = (string) $quote->getShippingAddress()->getCountryId();

        $restrictedSkus = $this->restrictionChecker->getRestrictedSkusForCountry($quote, $countryId);

        if ($restrictedSkus !== []) {
            throw new LocalizedException(__(
                'The following items cannot be shipped to %1: %2',
                $countryId,
                implode(', ', $restrictedSkus)
            ));
        }
    }
}

Important for this kind of checkout validation: the plugin throws a LocalizedException instead of a generic exception, so the error message is translatable and can be shown directly in the checkout frontend, without a generic error page interrupting the entire order process.

4. Cart rules: minimum order value and restricted products

Besides address rules, checkout validation frequently concerns the cart as a whole: a minimum order value for certain customer groups, a maximum quantity of certain items per order, or an exclusion of certain product combinations. These rules conceptually belong to the QuoteValidator, but can be implemented without a core override through a custom validator hooked into the existing validation flow via plugin or observer.

Registering such a validator happens through di.xml, by inserting the custom validation class as an additional element into the existing validator list instead of replacing the entire list. This additive registration ensures native validation rules, for example the stock check, remain unchanged.


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Quote\Api\CartManagementInterface">
        <plugin name="MironsoftCheckoutValidationMinimumOrderAmount"
                type="Mironsoft\CheckoutValidation\Plugin\ValidateMinimumOrderAmountPlugin"
                sortOrder="20" />
        <plugin name="MironsoftCheckoutValidationShippingRestrictions"
                type="Mironsoft\CheckoutValidation\Plugin\ValidateShippingRestrictionsPlugin"
                sortOrder="10" />
    </type>
</config>

The sort order of plugins is decisive when there are several validation rules: cheap, quickly evaluated checks such as a minimum order value should run before more expensive checks such as an external API call for shipping restrictions. This checkout validation ordering reduces unnecessary external calls when an order already fails on a simpler rule anyway.

5. Validating payment data before order completion

Another important point for checkout validation is the transition from payment selection to order placement. PaymentInformationManagementInterface::savePaymentInformationAndPlaceOrder is the central service contract through which most checkout frontends, including Hyvä Checkout, actually trigger an order. A plugin on this method is the right place to check project specific rules concerning both payment data and the order context, for example a required VAT number when paying on invoice as a company customer.

This validation should never attempt to process or store payment data itself, that remains the responsibility of the payment gateway layer. It only checks whether the prerequisites for the selected payment method are met before the actual payment process is even initiated.


<?php

declare(strict_types=1);

namespace Mironsoft\CheckoutValidation\Plugin;

use Magento\Checkout\Api\PaymentInformationManagementInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Quote\Api\CartRepositoryInterface;
use Magento\Quote\Api\Data\PaymentInterface;

/**
 * Requires a VAT number on the billing address when the customer selects
 * invoice payment as a registered company customer.
 */
class ValidateInvoicePaymentRequirementsPlugin
{
    private const INVOICE_PAYMENT_CODE = 'mironsoft_invoice';

    /**
     * @param CartRepositoryInterface $cartRepository
     */
    public function __construct(
        private readonly CartRepositoryInterface $cartRepository
    ) {
    }

    /**
     * @param PaymentInformationManagementInterface $subject
     * @param int $cartId
     * @param PaymentInterface $paymentMethod
     * @param mixed $billingAddress
     * @return void
     * @throws LocalizedException
     */
    public function beforeSavePaymentInformationAndPlaceOrder(
        PaymentInformationManagementInterface $subject,
        int $cartId,
        PaymentInterface $paymentMethod,
        $billingAddress = null
    ): void {
        if ($paymentMethod->getMethod() !== self::INVOICE_PAYMENT_CODE) {
            return;
        }

        $quote = $this->cartRepository->get($cartId);
        $vatId = trim((string) $quote->getBillingAddress()->getVatId());

        if ($vatId === '') {
            throw new LocalizedException(__(
                'A valid VAT number in the billing address is required for invoice payment.'
            ));
        }
    }
}

6. Client side validation in the Hyvä checkout

Despite the central role of server side checks, client side checkout validation remains important for the user experience. Nobody wants to fill out a form, click the order button, and only afterwards find out that a required field is missing. In the Hyvä checkout, Alpine.js provides this immediate feedback without requiring a server round trip, for example checking an email format or counting remaining characters in a text field.

It is important to treat this client side checkout validation explicitly as a convenience feature and never as a substitute for server side checking. An Alpine.js template can highlight a required field in color as soon as it stays empty, but actual enforcement of the rule must still happen in the plugin on the respective service contract.


<div x-data="{ vatId: '', touched: false }">
    <label class="block text-sm font-medium mb-1" for="vat_id">VAT number (required for invoice payment)</label>
    <input id="vat_id" type="text" x-model="vatId"
           @blur="touched = true"
           class="w-full border rounded-lg px-3 py-2"
           :class="touched && vatId.trim() === '' ? 'border-red-500' : 'border-slate-300'">
    <p x-show="touched && vatId.trim() === ''" class="text-red-600 text-sm mt-1">
        Please provide a valid VAT number.
    </p>
</div>

This kind of pre check noticeably reduces the number of failed order attempts, because customers see errors immediately instead of only learning about them after submission through a server side error message. Server side checkout validation nevertheless remains the final and authoritative instance in every case.

7. User friendly error messages and localization

Technically correct checkout validation that produces incomprehensible error messages frustrates customers just as much as missing validation. Error messages from plugins should always be localized through __(), concretely name which field or item is affected, and where possible give a hint on how the customer can fix the problem.

An error message like "Validation failed" without further context forces the customer to contact support, even though the actual problem, for example a restricted shipping address, would be immediately solvable by the customer with a more precise wording. Every custom checkout validation should therefore already consider, while writing the exception message, how it sounds to an end customer without technical background knowledge.

8. Avoiding race conditions on concurrent order placement

A subtle problem in checkout validation arises when two orders claim the same scarce resource at the same time, for example the last item in stock or a limited availability discount code. A validation that checks stock before order placement and then does nothing further can still lead to overselling if two requests pass the same check successfully nearly simultaneously, before either of them actually reduces the stock.

The reliable solution is to perform critical checks not only before, but atomically with the actual reservation, for example through a database transaction with pessimistic locking on the affected stock row. For less critical resources, a re-check immediately before the final commit of the order is often enough, combined with a clear error message if the resource has meanwhile been taken. A checkout validation that ignores this race condition problem works reliably in tests and still fails regularly under real load with concurrent orders.


#!/usr/bin/env bash
# Simulate two near-simultaneous order attempts for the same scarce resource
# to verify that checkout validation catches the race condition correctly
set -euo pipefail

CART_ID_A="123"
CART_ID_B="456"

curl -s -X PUT "https://shop.example.com/rest/V1/carts/mine/order" \
  -H "Authorization: Bearer $TOKEN_A" -d "{\"cartId\":\"$CART_ID_A\"}" &
curl -s -X PUT "https://shop.example.com/rest/V1/carts/mine/order" \
  -H "Authorization: Bearer $TOKEN_B" -d "{\"cartId\":\"$CART_ID_B\"}" &

wait
echo "Exactly one of the two requests above must fail with a clear stock error."

9. Validation layers compared

The following table maps typical validation rules to the appropriate layer, so a new rule ends up in the right place from the start.

Rule type Recommended layer Bypassable without server check? Example
Required field format Client + server Yes, without a server check Email format, VAT number
Shipping restriction Plugin on CartManagement Yes, without a server check Restricted countries per product
Minimum order value QuoteValidator extension Yes, without a server check Fifty euro minimum order for B2B
Scarce resource Transaction with locking Yes, race condition possible Last item in stock

It is notable that every business critical rule must be enforceable server side, while the client layer only serves the user experience. This clear separation prevents a supposedly complete checkout validation from being bypassed by a direct API call.

Mironsoft

Magento 2 checkout validation and business rules development

Custom validation rules for your checkout?

We implement business specific checkout validation in Magento 2, from address and cart rules through payment data checks to reliable protection against race conditions on scarce resources.

Service contract plugins

Hooking address and cart rules in without core overrides

Payment data validation

Reliably checking project specific rules before order completion

Race condition protection

Locking strategies for scarce resources under concurrent order placement

10. Summary

Solid checkout validation in Magento 2 is built on three layers: client side feedback for the user experience, plugins on service contracts such as CartManagementInterface and PaymentInformationManagementInterface for actual enforcement, and extensions on the domain model for cart wide rules. Each layer serves its own purpose, but only the server side layers are actually binding.

Particular attention deserves race conditions on scarce resources and the wording of understandable error messages. A checkout validation that works in tests but fails under real load with concurrent orders, or leaves customers with cryptic error messages, ultimately misses its actual purpose, namely reliably and comprehensibly preventing faulty orders.

Checkout validation in Magento 2, the essentials at a glance

Three layers

Client for feedback, service contract plugins for enforcement, domain model for cart wide rules.

Registration

Additive plugins via di.xml, never replace the existing validator list, only extend it.

Error messages

Always localized via __(), concrete with a hint on how to fix it, instead of a generic rejection.

Race conditions

Perform critical checks atomically with the reservation, not just beforehand, to guard against overselling.

11. FAQ: Checkout Validation in Magento 2

1How do I safely extend checkout validation?
Through plugins on service contracts, complemented by client side checks, with plugins remaining authoritative.
2Is client side validation in Hyvä enough?
No, it only improves user experience, every business critical rule needs server side enforcement.
3Where do I check shipping restrictions?
In a plugin on CartManagementInterface::placeOrder with a configurable restriction list.
4How do I register a custom cart validator?
Additively via di.xml, without replacing the existing validator list.
5Where do I check payment data prerequisites?
In a plugin on PaymentInformationManagementInterface::savePaymentInformationAndPlaceOrder.
6How should error messages look?
Localized, concrete about the affected field, with a hint on how to fix it instead of a generic rejection.
7What is a race condition in checkout?
Two orders check the same scarce resource almost simultaneously, which can lead to overselling.
8How do I prevent overselling?
Atomic check and reservation with pessimistic locking instead of separate steps.
9What order should plugins run in?
Cheap checks first, expensive external calls last, to avoid unnecessary cost.
10Can an API request bypass frontend validation?
Yes, so every business critical rule must be enforced server side regardless.