Custom checkout data
Before you start
Add a purchase-order reference to your checkout form, validate it before payment, and save it on the resulting Kirby order. The same pattern works for other shop-specific fields. An optional Stripe example forwards the reference to the payment provider.
Start with a working Kart checkout and order creation enabled (bnomei.kart.orders.enabled). This guide uses regular browser POST forms and the default bnomei.kart.router.mode = go. JSON and HTMX clients need their own validation-response handling.
Merge the configuration entries below into the array already returned by site/config/config.php. Keep one return statement, and combine any existing callbacks with these examples.
How the data moves
Checkout form
→ checkoutFormData callback: allowlist, normalize, validate
→ temporary Kirby session
→ provider checkout options: forward selected values
→ completed callback: add selected values to order data
→ Kart checks payment or authorization and creates the order
→ local order tab displays the saved field
These are separate jobs. A form submits a value; a callback decides whether to accept it; another callback adds it to the order; and a blueprint makes it editable in the Panel. Adding a blueprint field alone does not perform the other steps.
1. Add the field to your checkout form
Add the field to your existing form, or copy Kart’s snippets/kart/checkout-form.php into site/snippets/kart/checkout-form.php and adapt it. Keep any existing CAPTCHA or other checkout protection. This example requires a purchase-order reference.
<?php
$checkout = kart()->checkoutFormData();
$reference = $checkout['purchase_order'] ?? '';
$error = kirby()->session()->pull('shop.checkout.error');
?>
<?php if (is_string($error) && $error !== ''): ?>
<p role="alert"><?= esc($error) ?></p>
<?php endif ?>
<form method="POST" action="<?= kart()->urls()->cart_checkout() ?>">
<?php snippet('kart/input-csrf') ?>
<input type="hidden" name="redirect"
value="<?= esc($page->url(), 'attr') ?>">
<label for="purchase-order">Purchase-order reference</label>
<input id="purchase-order" name="purchase_order" type="text"
required maxlength="100"
value="<?= esc((string) $reference, 'attr') ?>">
<button type="submit"
<?= kart()->cart()->canCheckout() ? '' : 'disabled' ?>>
Continue to payment
</button>
</form>
The hidden redirect value returns the customer to this page when validation rejects the submission. The CSRF snippet uses Kart’s default token field; adapt it if you changed that setting. HTML required and maxlength help the customer, but the server must validate the value too.
2. Allowlist and validate the submitted value
Return an array containing only the values you accept. Return null to reject the submission and redirect back to the form. The callback below drops unrelated POST keys, rejects arrays and oversized input, removes HTML tags, and requires a nonempty normalized reference.
<?php
use Bnomei\Kart\Kart;
return [
'bnomei.kart.checkoutFormData' => static function (array $input): ?array {
$data = [];
$session = kirby()->session();
$reject = static function (string $message) use ($session, &$data): ?array {
$session->set('shop.checkout.error', $message);
// Rejected submissions are not saved by the checkout route.
$session->set('bnomei.kart.checkout_form_data', $data);
return null;
};
$reference = $input['purchase_order'] ?? '';
if (!is_string($reference) || mb_strlen($reference) > 100) {
return $reject('Use a purchase-order reference of at most 100 characters.');
}
$reference = Kart::sanitize($reference);
if (!is_string($reference)) {
return $reject('Enter a valid purchase-order reference.');
}
$data['purchase_order'] = trim($reference);
if ($data['purchase_order'] === '') {
return $reject('Enter your purchase-order reference.');
}
$session->remove('shop.checkout.error');
return $data;
},
];
The explicit $data array is the allowlist. If your form also collects email, addresses, or another custom field, validate those values and add them explicitly before returning. Never return the complete request unchanged.
Kart sanitizes accepted data and stores it at bnomei.kart.checkout_form_data before starting provider checkout. Its sanitization strips tags and limits the encoded payload to 10,000 bytes; it does not replace field validation or output escaping. Keep all checkout values small and apply server-side limits to every field.
The rejection helper replaces previous temporary checkout values with the safe subset already prepared by the callback. Invalid types and oversized values are deliberately not echoed back. When adapting this to several fields, retain only normalized, bounded values that are safe to display.
3. Add the value to order data
Use bnomei.kart.completed to copy the accepted reference into the data used to create the order. Copy only the intended custom keys.
<?php
return [
'bnomei.kart.completed' => static function (
array $orderData,
array $checkoutFormData,
): array {
return $orderData + [
'purchase_order' => $checkoutFormData['purchase_order'] ?? '',
];
},
];
The array union keeps existing provider values on a key collision. The explicit key selection also prevents unrelated input from becoming order content. Keep custom names separate from Kart’s payment IDs, status, items, totals, customer references, and other reserved fields.
This callback prepares completion data; it is not proof that payment succeeded. Kart checks payment or authorization before creating an order. Use the cart completion hooks for fulfillment actions.
4. Show the field in the Panel
Create or extend site/blueprints/tabs/order-local.yml. Kart’s order blueprint already includes this local tab, so you can add your field while continuing to receive the plugin’s blueprint updates. Merge this field into your existing local tab if you have one.
label: Checkout data
icon: user
fields:
purchase_order:
label: Purchase-order reference
type: text
After a successful test checkout, open the order in the Panel. The saved reference appears in the local tab. In a template that has an order object, read the stored field with:
<?= esc($order->content()->get('purchase_order')->value()) ?>
You do not need to replace the generated pages/order.yml. See extending the order blueprint for the local-tab extension point.
5. Optionally forward the reference to Stripe
Complete the Stripe provider setup first. For a one-time payment, read the accepted session data from $kart->checkoutFormData() and add the reference to Stripe metadata.
<?php
use Bnomei\Kart\Kart;
return [
'bnomei.kart.providers.stripe.checkout_options' => static function (Kart $kart): array {
$reference = $kart->checkoutFormData()['purchase_order'] ?? '';
if ($reference === '') {
return [];
}
$metadata = ['purchase_order' => $reference];
return [
'metadata' => $metadata,
'payment_intent_data' => [
'metadata' => $metadata,
],
];
},
];
Checkout Session metadata and PaymentIntent metadata belong to separate objects. This example writes to both; omit the PaymentIntent entry if you only need the reference on the Session. Use string values and keep sensitive personal or payment information out of metadata. See Stripe’s metadata documentation.
Kart merges the returned options over its defaults at the top level. If your callback already sets payment_intent_data, put metadata and any other nested settings together in that same array. A second configuration callback or a second copy of the key would replace the first. Consult the Checkout Session API for your account’s API version and payment mode.
Contact details and addresses
Add contact or address fields to the same acceptance callback when your shop needs them before payment. Validate email, required address components, lengths, and the countries your shop supports. An uppercase two-letter pattern alone does not prove a country code exists.
Kart’s provider helpers recognize contact names such as email, phone, first_name, and last_name (or name), plus address names such as address1, address2, city, state, postal_code, and country. What reaches the payment service depends on the provider implementation.
Stripe uses accepted email data for its default customer_email. Custom addresses are not automatically forwarded. To collect a billing address in hosted Stripe Checkout, add billing_address_collection with the value required to your checkout options. A shipping address collected in Kirby needs explicit mapping to a supported structured Stripe parameter, such as payment_intent_data.shipping; do not send addresses as arbitrary metadata.
Keep your own order fields distinct from provider-managed values. For example, use checkout_contact_email if you need to retain an independently collected contact email. Kart handles email, customer, and uuid specially during order creation, so they are not ordinary custom content keys.
Blueprint flags and additional fields
Some projects mark fields with checkout: true and read those definitions in a custom callback to build an allowlist. That flag is an application convention: Kart does not automatically accept, validate, or save fields marked this way. The explicit allowlist above needs no custom blueprint flag.
For each additional field, extend the form, server validation, completion mapping, and local order tab together. Add provider mapping only when that service needs the value. Handle uploaded files separately; file data is not part of this text-field workflow.
Temporary data and retries
During the same browser checkout session, read the accepted values with kart()->checkoutFormData(). Kart clears that data after order completion, including its duplicate-order cleanup. Read permanent values from the order afterward.
Canceling provider checkout does not itself clear this data. Decide whether your form should reuse it on retry or explicitly clear it. Remove any custom session keys you introduce when they are no longer needed. Independent webhook requests do not share the customer’s browser session; do not rely on this temporary storage for webhook processing.
Verify the complete flow
- Submit an empty, array-valued, or oversized reference. Confirm checkout returns to the form with an error and does not start payment. Test server validation without relying on the browser’s input constraints.
- Submit a valid reference alongside an unexpected POST field. Confirm only the allowlisted value reaches the temporary checkout data.
- Complete a test payment. Open the Kirby order and confirm the reference appears in the local tab and payment fields remain intact.
- If you use the Stripe example, inspect the Checkout Session and PaymentIntent for the reference.
- Cancel a checkout and retry. Confirm the form behaves as intended and cancellation has not created an order.
- After completion, confirm
kart()->checkoutFormData()is empty and the order still holds the reference.
Troubleshooting
- Validation returns to the wrong page: check the form’s hidden
redirectvalue. - The field reaches checkout but is missing from the order: check the
completedmapping and that order creation is enabled. - The value exists but is not visible in the Panel: match the field name in
tabs/order-local.ymlexactly. - Stripe does not show the reference: verify the checkout-options callback is active, then inspect the correct Stripe object.
Related documentation: Cart and checkout, Orders, Configuration, and Stripe.