Commit b2836b83fc7 for woocommerce

commit b2836b83fc7241f700ccaf03059ce85b274e0faa
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date:   Fri Sep 11 15:43:41 2026 +0200

    Add min/max constraints to date checkout fields (#68083)

    * Fix conditional field rules matching when a field has no value yet

    * Add changelog entry for conditional checkout field rules fix

    * Remove leftover experimental-blocks gate from field persistence

    * Update feature flag docs after ungating conditional field persistence

    * update changelog

    * remove duplicate changelog (thanks AI!)

    * Add conditional rule tests for contact and address location fields

    * Remove redundant AI comment

    * update comment

    * Provide schema and order properties for CheckoutTrait in DocumentObject tests

    * Fix saved checkout field values during validation

    * Update changelog for conditional checkout field fixes

    * Fix nullable order handling in checkout payment route

    * Add min/max constraints to date checkout fields

    Adds optional min and max options to date additional checkout fields,
    accepting an absolute YYYY-MM-DD date, an ISO 8601-2 duration relative to
    today such as P1D or -P18Y, or a DateInterval. Durations are resolved on
    every read on both the client and the server so they stay correct behind a
    page cache.

    * Simplify date field min/max constraint handling

    Drop DateInterval support for date field min/max constraints, keeping
    absolute YYYY-MM-DD dates and signed ISO 8601-2 durations. Constraint
    validation and normalization move inline into process_options, and
    resolve_constraint / get_constraints become private.

    Restructure DateFieldTypeTest around registration, prepare_form_field
    and validation, and trim the CheckoutFields tests to the delegation
    they still cover. Update the docs and changelog to match.

    * Apply date field min/max constraints on the order edit screen

    Route admin meta box fields through CheckoutFields::prepare_form_field()
    so the order edit screen picks up the same type-specific arguments the
    My Account forms get, including the resolved date min/max attributes.
    This replaces the select and checkbox handling that format_field_for_meta_box
    duplicated inline.

    DateFieldType::prepare_form_field() now merges into custom_attributes
    rather than replacing them, so it does not clobber attributes another
    consumer set.

    * Drop the admin constraint docs and test additions

    Reverts the doc paragraph and docblock added alongside the meta box
    change, and removes CheckoutFieldsAdminTest. The prepare_form_field()
    routing in CheckoutFieldsAdmin is unchanged, so the order edit screen
    still renders the resolved date bounds.

    * Fix date checkout field limits after review

    * Update date field constraint guidance

    * Update docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md

diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
index e014c20a459..8cae00c94ab 100644
--- a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
@@ -252,7 +252,48 @@ Text fields don't have any additional options beyond the general options listed

 #### Options for `date` fields

-Date fields don't have any additional options beyond the general options listed above.
+As well as the options above, date fields support `min` and `max` options to limit the range of dates a shopper can pick.
+
+| Option name | Description | Required? | Example | Default value |
+| --- | --- | --- | --- | --- |
+| `min` | The earliest date the shopper can select. | No | `2026-01-01`, `P0D`, `P1D` | No minimum. |
+| `max` | The latest date the shopper can select. | No | `2026-12-31`, `P30D`, `-P18Y` | No maximum. |
+
+Each one takes either:
+
+- An **absolute** date in `YYYY-MM-DD` format, such as `2026-01-01`. This is the same format the HTML `min` and `max` attributes use.
+- A **duration relative to today**, written in the [ISO 8601-2 duration format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration#iso_8601_duration_format), optionally signed: `P1D` (tomorrow), `-P5D` (five days ago), `P2W`, `P3M`, `-P18Y`. `P0D` means today.
+
+```php
+woocommerce_register_additional_checkout_field(
+	array(
+		'id'       => 'my-plugin/delivery-date',
+		'label'    => 'Preferred delivery date',
+		'location' => 'order',
+		'type'     => 'date',
+		'required' => true,
+		'min'      => 'P1D',   // From tomorrow,
+		'max'      => 'P30D',  // up to 30 days out.
+	)
+);
+```
+
+##### Dates only, not times
+
+A date field holds a calendar date with no time component, so only the `Y`, `M`, `W` and `D` parts of a duration are meaningful. A duration carrying a time component, such as `PT1H` or `P1DT12H`, is rejected at registration.
+
+##### Pass the duration, don't resolve it yourself
+
+```php
+// Don't do this.
+'min' => date( 'Y-m-d', strtotime( '+1 day' ) ),
+```
+
+This ends up resolving to a date that may not always be up to date between registration, field rendering, and value submission. Instead, pass P1D, which will be evaluated at input time and submission time.
+
+Registration fails with a `_doing_it_wrong` notice if a constraint can't be parsed. Express both bounds in the same unit, i.e. avoid `'min' => 'P30D'`, `'max' => 'P1M'` as it would form an invalid range in February for example. Avoid mixing absolute and durations unless you're sure they won't overlap at some point in the future.
+
+If mixed dates (absolute and durations) resolve to an invalid range, where min is later than max, WooCommerce will ignore them and the field will be boundless and will emit a log warning.

 The input value will follow the browser's locale settings, the DB value will be in YYYY-MM-DD, and the final rendered value (in pages and emails) will follow the site's date format, set in **Settings -> General**.

diff --git a/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md b/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md
index 8d0a5d6365a..2616b6597dd 100644
--- a/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md
+++ b/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md
@@ -182,6 +182,24 @@ woocommerce_register_additional_checkout_field(

 The field input value will follows the shopper's browser and OS locale. The value is always stored as `YYYY-MM-DD`, and it is displayed using the site's date format (**Settings → General**) in emails, order screens, and other places the value is rendered.

+Use `min` and `max` to limit the range of dates a shopper can pick. Both accept an absolute date in `Y-m-d` format, or a duration relative to today in the [ISO 8601-2 duration format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration#iso_8601_duration_format), such as `P1D`, `-P5D` or `-P18Y`:
+
+```php
+woocommerce_register_additional_checkout_field(
+    array(
+        'id'       => 'my-plugin/delivery-date',
+        'label'    => __('Preferred delivery date', 'your-text-domain'),
+        'location' => 'order',
+        'type'     => 'date',
+        'required' => true,
+        'min'      => 'P1D',
+        'max'      => 'P30D',
+    )
+);
+```
+
+Pass the duration rather than resolving it yourself with `strtotime()` or `date()`. WooCommerce resolves it against the current date each time the field is rendered or validated, so it stays correct behind a page cache and across midnight.
+
 ## Adding Field Attributes

 You can enhance your fields with HTML attributes for better user experience:
diff --git a/plugins/woocommerce/changelog/wooplug-6456-date-field-min-max b/plugins/woocommerce/changelog/wooplug-6456-date-field-min-max
new file mode 100644
index 00000000000..7c71a2897f5
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6456-date-field-min-max
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add `min` and `max` options to `date` additional checkout fields, accepting an absolute date or an ISO 8601-2 duration relative to today such as `P1D` or `-P18Y`.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/date-constraints.ts b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/date-constraints.ts
new file mode 100644
index 00000000000..4fa6d5b853d
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/date-constraints.ts
@@ -0,0 +1,96 @@
+/**
+ * External dependencies
+ */
+import { date as formatDate } from '@wordpress/date';
+import * as DurationFns from 'temporal-polyfill/fns/Duration';
+import * as PlainDateFns from 'temporal-polyfill/fns/PlainDate';
+
+// Remove once TypeScript's lib ships Temporal types.
+/* eslint-disable @typescript-eslint/naming-convention -- Temporal's own class names. */
+declare const Temporal:
+	| {
+			PlainDate: {
+				from: ( date: string ) => {
+					add: ( duration: unknown ) => { toString: () => string };
+				};
+			};
+			Duration: { from: ( duration: string ) => unknown };
+	  }
+	| undefined;
+/* eslint-enable @typescript-eslint/naming-convention */
+
+/**
+ * Resolves an ISO 8601-2 duration to a date relative to today.
+ * We use the store timezone (from wp.date.date) so dates match between server and client.
+ *
+ * Uses native `Temporal` where available, falling back to the polyfill.
+ */
+const resolveDuration = ( duration: string ): string => {
+	const today = formatDate( 'Y-m-d', new Date() );
+
+	if ( typeof Temporal !== 'undefined' ) {
+		return Temporal.PlainDate.from( today )
+			.add( Temporal.Duration.from( duration ) )
+			.toString();
+	}
+
+	const [ year, month, day ] = today.split( '-' ).map( Number );
+
+	return PlainDateFns.toString(
+		PlainDateFns.add(
+			PlainDateFns.create( year, month, day ),
+			DurationFns.fromString( duration )
+		)
+	);
+};
+
+/**
+ * Resolves a date field's min/max constraint to a YYYY-MM-DD value for a date input.
+ *
+ * A constraint is either an absolute YYYY-MM-DD date or an ISO 8601-2 duration relative to today,
+ * such as `P1D` or `-P18Y`. WooCommerce resolves the same expression in PHP when the submitted value is validated.
+ *
+ * @param constraint The constraint as registered, or undefined when the field is unconstrained.
+ * @return The resolved date, or undefined if there is no constraint or it could not be parsed.
+ */
+export const resolveDateConstraint = (
+	constraint: string | undefined
+): string | undefined => {
+	if ( ! constraint ) {
+		return undefined;
+	}
+
+	const value = constraint.trim();
+
+	// If it's already a date, return it.
+	if ( /^\d{4}-\d{2}-\d{2}$/.test( value ) ) {
+		return value;
+	}
+
+	try {
+		return resolveDuration( value );
+	} catch {
+		return undefined;
+	}
+};
+
+/**
+ * Resolves both date limits and drops them when the range has no valid date.
+ *
+ * @param field     The date field's registered constraints.
+ * @param field.min The minimum date or duration.
+ * @param field.max The maximum date or duration.
+ */
+export const resolveDateConstraints = ( field: {
+	min?: string;
+	max?: string;
+} ): { min: string | undefined; max: string | undefined } => {
+	const min = resolveDateConstraint( field.min );
+	const max = resolveDateConstraint( field.max );
+
+	if ( min && max && min > max ) {
+		return { min: undefined, max: undefined };
+	}
+
+	return { min, max };
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx
index ea81f6a549d..ca5bdcd591b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx
@@ -38,6 +38,7 @@ import fastDeepEqual from 'fast-deep-equal/es6';
  */
 import { Select } from '../../select';
 import AddressLineFields from './address-line-fields';
+import { resolveDateConstraints } from './date-constraints';
 import { FormProps } from './types';
 import { useFormFields } from './use-form-fields';
 import { useFormValidation } from './use-form-validation';
@@ -422,16 +423,22 @@ const Form = <
 						}
 						{ ...fieldProps }
 						type={ field.type }
-						icon={
-							field.type === 'date' ? (
-								<span
-									className="wc-block-components-text-input__date-icon"
-									aria-hidden="true"
-								>
-									<Icon icon={ calendar } size={ 24 } />
-								</span>
-							) : null
-						}
+						{ ...( field.type === 'date'
+							? {
+									...resolveDateConstraints( field ),
+									icon: (
+										<span
+											className="wc-block-components-text-input__date-icon"
+											aria-hidden="true"
+										>
+											<Icon
+												icon={ calendar }
+												size={ 24 }
+											/>
+										</span>
+									),
+							  }
+							: {} ) }
 						ariaDescribedBy={ ariaDescribedBy }
 						value={
 							decodeEntities(
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/date-constraints.ts b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/date-constraints.ts
new file mode 100644
index 00000000000..abe7c00ee89
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/date-constraints.ts
@@ -0,0 +1,122 @@
+/**
+ * Internal dependencies
+ */
+import {
+	resolveDateConstraint,
+	resolveDateConstraints,
+} from '../date-constraints';
+
+describe( 'resolveDateConstraint', () => {
+	afterEach( () => {
+		jest.useRealTimers();
+	} );
+
+	const onDate = ( today: string ) =>
+		jest
+			.useFakeTimers()
+			.setSystemTime( new Date( `${ today }T12:00:00Z` ) );
+
+	it( 'returns undefined when there is no constraint', () => {
+		expect( resolveDateConstraint( undefined ) ).toBeUndefined();
+	} );
+
+	it.each( [ [ 'not-a-date' ], [ '' ], [ 'today' ], [ '+1 day' ], [ 'P' ] ] )(
+		'returns undefined for the unparseable constraint %s',
+		( value ) => {
+			expect( resolveDateConstraint( value ) ).toBeUndefined();
+		}
+	);
+
+	it( 'passes an absolute date through', () => {
+		expect( resolveDateConstraint( '2026-01-01' ) ).toBe( '2026-01-01' );
+	} );
+
+	it.each( [
+		[ 'P0D', '2026-08-26' ],
+		[ 'P1D', '2026-08-27' ],
+		[ '-P5D', '2026-08-21' ],
+		[ 'P2W', '2026-09-09' ],
+		[ 'P1W2D', '2026-09-04' ],
+		[ '-P1W2D', '2026-08-17' ],
+		[ 'P1M2W', '2026-10-10' ],
+		[ 'P3M', '2026-11-26' ],
+		[ '-P18Y', '2008-08-26' ],
+		[ 'P1Y2M3D', '2027-10-29' ],
+	] )( 'resolves %s to %s', ( constraint, expected ) => {
+		onDate( '2026-08-26' );
+
+		expect( resolveDateConstraint( constraint ) ).toBe( expected );
+	} );
+
+	// Edge cases in which adding a period to a date should resolve based on the period, not number of dates.
+	// For example, adding 1 month to Jan 31 should resolve to Feb 28, not Mar 3. This is a bug in PHP that
+	// we had to fix, and we test for here regardless.
+	it.each( [
+		[ '2026-01-31', 'P1M', '2026-02-28' ],
+		[ '2026-03-31', '-P1M', '2026-02-28' ],
+		[ '2024-02-29', 'P1Y', '2025-02-28' ],
+		[ '2026-01-31', 'P1M15D', '2026-03-15' ],
+		[ '2026-01-31', 'P1M2W3D', '2026-03-17' ],
+	] )(
+		'clamps %s + %s to the end of the target month, giving %s',
+		( today, constraint, expected ) => {
+			onDate( today );
+
+			expect( resolveDateConstraint( constraint ) ).toBe( expected );
+		}
+	);
+
+	it.each( [
+		[ '2026-12-31', '2026-01-01' ],
+		[ 'P2M', 'P1M' ],
+		[ 'P0D', '2026-08-25' ],
+		[ '2026-08-27', 'P0D' ],
+	] )( 'drops both limits for an inverted range %s to %s', ( min, max ) => {
+		onDate( '2026-08-26' );
+
+		expect( resolveDateConstraints( { min, max } ) ).toEqual( {
+			min: undefined,
+			max: undefined,
+		} );
+	} );
+
+	it.each( [
+		[ 'P0D', 'P0D', '2026-08-26', '2026-08-26' ],
+		[ 'P0D', 'P1D', '2026-08-26', '2026-08-27' ],
+		[ undefined, 'P0D', undefined, '2026-08-26' ],
+		[ 'P0D', undefined, '2026-08-26', undefined ],
+	] )(
+		'keeps valid limits %s to %s',
+		( min, max, expectedMin, expectedMax ) => {
+			onDate( '2026-08-26' );
+
+			expect( resolveDateConstraints( { min, max } ) ).toEqual( {
+				min: expectedMin,
+				max: expectedMax,
+			} );
+		}
+	);
+
+	it( 'drops a mixed range when the minimum moves past the maximum', () => {
+		const field = { min: 'P0D', max: '2026-08-26' };
+		onDate( '2026-08-26' );
+		expect( resolveDateConstraints( field ) ).toEqual( {
+			min: '2026-08-26',
+			max: '2026-08-26',
+		} );
+
+		onDate( '2026-08-27' );
+		expect( resolveDateConstraints( field ) ).toEqual( {
+			min: undefined,
+			max: undefined,
+		} );
+	} );
+
+	it( 'follows the clock rather than the moment the page was rendered', () => {
+		onDate( '2026-08-26' );
+		expect( resolveDateConstraint( 'P0D' ) ).toBe( '2026-08-26' );
+
+		jest.setSystemTime( new Date( '2026-08-27T12:00:00Z' ) );
+		expect( resolveDateConstraint( 'P0D' ) ).toBe( '2026-08-27' );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/package.json b/plugins/woocommerce/client/blocks/package.json
index 06ea3c5fe45..2b4f3140b77 100644
--- a/plugins/woocommerce/client/blocks/package.json
+++ b/plugins/woocommerce/client/blocks/package.json
@@ -263,6 +263,7 @@
 		"prop-types": "^15.8.1",
 		"react-number-format": "5.4.5",
 		"react-transition-group": "^4.4.5",
+		"temporal-polyfill": "1.0.4",
 		"trim-html": "0.1.9",
 		"use-debounce": "9.0.4",
 		"usehooks-ts": "^2.9.1",
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/settings/default-fields.ts b/plugins/woocommerce/client/blocks/packages/public-api/settings/default-fields.ts
index 3c848574733..61d9f387ead 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/settings/default-fields.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/settings/default-fields.ts
@@ -50,6 +50,10 @@ export interface Field {
 	options?: SelectOption[];
 	// The placeholder for the field, only applicable for select fields.
 	placeholder?: string;
+	// The earliest date a date field accepts, as YYYY-MM-DD or an ISO 8601-2 duration such as `P1D`.
+	min?: string;
+	// The latest date a date field accepts, as YYYY-MM-DD or an ISO 8601-2 duration such as `-P18Y`.
+	max?: string;
 	// Additional attributes added when registering a field. String in key is required for data attributes.
 	attributes?: Record< keyof CustomFieldAttributes, string >;
 }
diff --git a/plugins/woocommerce/client/blocks/tests/js/jest.config.js b/plugins/woocommerce/client/blocks/tests/js/jest.config.js
index f714f3b5649..e52facc6b52 100644
--- a/plugins/woocommerce/client/blocks/tests/js/jest.config.js
+++ b/plugins/woocommerce/client/blocks/tests/js/jest.config.js
@@ -138,7 +138,8 @@ module.exports = {
 		'^.+\\.(js|ts|tsx)$': '<rootDir>/tests/js/scripts/babel-transformer.js',
 	},
 	transformIgnorePatterns: [
-		'/node_modules/(?!\\.pnpm/dinero\\.js|dinero\\.js)',
+		// temporal-polyfill and its deps are ESM-only, so they need transforming too.
+		'/node_modules/(?!\\.pnpm/dinero\\.js|dinero\\.js|\\.pnpm/temporal-|temporal-)',
 	],
 	verbose: true,
 	cacheDirectory: '<rootDir>/../../node_modules/.cache/jest',
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
index e8c96fb140e..3d6f8b5c53a 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
@@ -9,10 +9,114 @@ use WP_Error;
 /**
  * The "date" additional checkout field type.
  *
- * Values are calendar dates in YYYY-MM-DD format with no time or timezone component.
+ * Values are calendar dates in YYYY-MM-DD format with no time or timezone component. Field
+ * also accept min/max constraints as absolute dates (i.e. YYYY-MM-DD) or relative
+ * ISO 8601-2 periods (e.g. -P2Y, P1M).
  */
 class DateFieldType extends AbstractFieldType {

+	/**
+	 * Matches a date in YYYY-MM-DD format.
+	 */
+	private const ABSOLUTE_DATE = '/^\d{4}-\d{2}-\d{2}$/';
+
+	/**
+	 * Processes the options for a date field and returns the new field_options array.
+	 *
+	 * @param array $field_data The field data array to be updated.
+	 * @param array $options    The options supplied during field registration.
+	 * @return array|false The updated $field_data array, or false if an error was encountered.
+	 */
+	protected function process_type_options( array $field_data, array $options ) {
+		$id = $options['id'];
+
+		foreach ( array( 'min', 'max' ) as $constraint ) {
+			// Both are optional. Drop the key entirely rather than carrying a null through to the client.
+			if ( ! isset( $options[ $constraint ] ) ) {
+				unset( $field_data[ $constraint ] );
+				continue;
+			}
+
+			$value = $options[ $constraint ];
+
+			if ( ! is_string( $value ) ) {
+				return $this->registration_error(
+					$id,
+					sprintf( 'The "%s" property of a "date" field must be a date in YYYY-MM-DD format or an ISO 8601-2 duration such as "P1D" or "-P18Y".', $constraint ),
+					'11.2.0'
+				);
+			}
+
+			$value = trim( $value );
+
+			if ( $this->is_absolute_date( $value ) ) {
+				if ( null === $this->parse_date( $value ) ) {
+					return $this->registration_error(
+						$id,
+						sprintf( 'The "%s" property of a "date" field must be a real calendar date, and "%s" is not.', $constraint, $value ),
+						'11.2.0'
+					);
+				}
+
+				$field_data[ $constraint ] = $value;
+				continue;
+			}
+
+			// DateInterval implements ISO 8601-1, which has no sign, so the ISO 8601-2 sign is peeled off first.
+			$sign = 1;
+			$body = $value;
+
+			if ( '' !== $body && in_array( $body[0], array( '+', '-' ), true ) ) {
+				$sign = '-' === $body[0] ? -1 : 1;
+				$body = substr( $body, 1 );
+			}
+
+			try {
+				$interval = self::parse_duration( $body );
+			} catch ( \Exception $e ) {
+				return $this->registration_error(
+					$id,
+					sprintf( 'The "%s" property of a "date" field must be a date in YYYY-MM-DD format or an ISO 8601-2 duration such as "P1D" or "-P18Y", and "%s" is neither.', $constraint, $value ),
+					'11.2.0'
+				);
+			}
+
+			// A date field has no sub-day precision, so a time component means the caller meant something else.
+			if ( $interval->h || $interval->i || $interval->s || $interval->f ) {
+				return $this->registration_error(
+					$id,
+					sprintf( 'The "%s" property of a "date" field must be a duration in whole days, but "%s" includes a time component.', $constraint, $value ),
+					'11.2.0'
+				);
+			}
+
+			// Keep the duration unresolved and drop an explicit plus sign.
+			$field_data[ $constraint ] = ( -1 === $sign ? '-' : '' ) . $body;
+		}
+
+		return $field_data;
+	}
+
+	/**
+	 * Parses a duration without losing weeks on PHP 7.
+	 *
+	 * @param string $duration The unsigned ISO duration.
+	 * @return \DateInterval The parsed duration.
+	 */
+	private static function parse_duration( string $duration ): \DateInterval {
+		if ( version_compare( PHP_VERSION, '8.0', '<' ) ) {
+			$duration = preg_replace_callback(
+				'/(\d+)W(?:(\d+)D)?/',
+				static function ( $matches ) {
+					return ( (int) $matches[1] * 7 + (int) ( $matches[2] ?? 0 ) ) . 'D';
+				},
+				$duration
+			) ?? '';
+		}
+
+		return new \DateInterval( $duration );
+	}
+
 	/**
 	 * Trims whitespace from submitted date values before they are validated and stored.
 	 *
@@ -25,7 +129,7 @@ class DateFieldType extends AbstractFieldType {
 	}

 	/**
-	 * Validates that a submitted value is a real calendar date.
+	 * Validates that a submitted value is a real calendar date within the field's min/max constraints.
 	 *
 	 * @param mixed $value The submitted value.
 	 * @param array $field The field.
@@ -48,6 +152,33 @@ class DateFieldType extends AbstractFieldType {
 			);
 		}

+		[ 'min' => $min, 'max' => $max ] = $this->get_constraints( $field, true );
+
+		// Both sides are YYYY-MM-DD, so a string comparison orders them correctly.
+		if ( $min && $value < $min ) {
+			return new WP_Error(
+				'woocommerce_invalid_checkout_field',
+				sprintf(
+					/* translators: 1: is the field label, 2: is the earliest date allowed */
+					__( 'Please provide a %1$s on or after %2$s.', 'woocommerce' ),
+					$field['label'],
+					$this->format_value( $min, $field )
+				)
+			);
+		}
+
+		if ( $max && $value > $max ) {
+			return new WP_Error(
+				'woocommerce_invalid_checkout_field',
+				sprintf(
+					/* translators: 1: is the field label, 2: is the latest date allowed */
+					__( 'Please provide a %1$s on or before %2$s.', 'woocommerce' ),
+					$field['label'],
+					$this->format_value( $max, $field )
+				)
+			);
+		}
+
 		return null;
 	}

@@ -70,6 +201,97 @@ class DateFieldType extends AbstractFieldType {
 		return false === $formatted ? $value : $formatted;
 	}

+	/**
+	 * Adds the resolved min/max constraints as input attributes for woocommerce_form_field().
+	 *
+	 * These forms are rendered server side, so the constraints are resolved here rather than by the client.
+	 *
+	 * @param array $form_field The woocommerce_form_field() arguments built from the field.
+	 * @return array The updated arguments.
+	 */
+	public function prepare_form_field( array $form_field ): array {
+		$form_field['custom_attributes'] = array_merge(
+			isset( $form_field['custom_attributes'] ) && is_array( $form_field['custom_attributes'] ) ? $form_field['custom_attributes'] : array(),
+			array_filter( $this->get_constraints( $form_field ) )
+		);
+
+		return $form_field;
+	}
+
+	/**
+	 * Returns the resolved min and max dates for a date field.
+	 *
+	 * @param array $field             The field.
+	 * @param bool  $log_invalid_range Whether to log an invalid range during value validation.
+	 * @return array Array with "min" and "max" keys, each a YYYY-MM-DD date or null when unconstrained.
+	 */
+	private function get_constraints( array $field, bool $log_invalid_range = false ): array {
+		$constraints = array(
+			'min' => isset( $field['min'] ) ? $this->resolve_constraint( $field['min'] ) : null,
+			'max' => isset( $field['max'] ) ? $this->resolve_constraint( $field['max'] ) : null,
+		);
+
+		if ( isset( $constraints['min'], $constraints['max'] ) && $constraints['min'] > $constraints['max'] ) {
+			if ( $log_invalid_range ) {
+				wc_get_logger()->warning(
+					sprintf( 'Date limits for checkout field "%s" were ignored because min (%s) is after max (%s).', $field['id'], $constraints['min'], $constraints['max'] ),
+					array( 'source' => 'checkout-fields' )
+				);
+			}
+
+			return array(
+				'min' => null,
+				'max' => null,
+			);
+		}
+
+		return $constraints;
+	}
+
+	/**
+	 * Resolves a date field min/max constraint to a YYYY-MM-DD date in the store's timezone.
+	 *
+	 * Durations are resolved at read time, so they follow the current date rather
+	 * than freezing into whatever markup a page cache stored.
+	 *
+	 * We use DateInterval to resolve durations but it has 2 issues that we must solve here:
+	 * 1. Negative constraints are not supported with this version, so we must account for them ourselves.
+	 * 2. DateInterval has an inconsistent behavior compared to ISO 8601-2 in which adding
+	 * a month to a date equals adding 31 days instead of a calendar month,
+	 * resulting in P1M after Jan 31 giving you Mar 3 instead of Feb 28.
+	 *
+	 * @param string                  $value     The constraint, as validated and normalized at registration.
+	 * @param \DateTimeInterface|null $reference Date a duration is relative to. Defaults to today in the store timezone.
+	 * @return string The resolved date.
+	 */
+	private function resolve_constraint( string $value, $reference = null ): string {
+		if ( $this->is_absolute_date( $value ) ) {
+			return $value;
+		}
+
+		$sign     = '-' === $value[0] ? -1 : 1;
+		$interval = self::parse_duration( ltrim( $value, '-' ) );
+		$today    = new \DateTimeImmutable( $reference ? $reference->format( 'Y-m-d' ) : 'today', wp_timezone() );
+
+		// Handles the DateInterval bug in which months days overflow to the next month instead of staying in the current month.
+		// P1M to Jan 31 gives you Mar 3 instead of Feb 28.
+		// So we deconstruct the period into years, months, and days, and then add them back separately.
+		$months = $sign * ( $interval->y * 12 + $interval->m );
+		$days   = $sign * $interval->d;
+		// Adding x months to today and asking for the last day of that month, then clamping the day to today if it exceeds it.
+		$end_of_month = $today->modify( sprintf( 'last day of %+d months', $months ) );
+		$resolved     = $end_of_month->setDate(
+			(int) $end_of_month->format( 'Y' ),
+			(int) $end_of_month->format( 'n' ),
+			// Asking for min will get us the correct end of month if we're about to overflow, or the actual date.
+			// Because adding P1M to Jan 17 should give you Feb 17 but adding P1M to Jan 28-31 should give you Feb 28 (or Feb 29 on leap years).
+			min( (int) $today->format( 'j' ), (int) $end_of_month->format( 'j' ) )
+		);
+
+		// Add the days back from the original interval.
+		return $resolved->modify( sprintf( '%+d days', $days ) )->format( 'Y-m-d' );
+	}
+
 	/**
 	 * Parses a YYYY-MM-DD date in the store's timezone.
 	 *
@@ -85,4 +307,14 @@ class DateFieldType extends AbstractFieldType {

 		return $date ? $date : null;
 	}
+
+	/**
+	 * Returns true if the given value is a date in YYYY-MM-DD format.
+	 *
+	 * @param mixed $value The value to check.
+	 * @return bool
+	 */
+	private function is_absolute_date( $value ): bool {
+		return is_string( $value ) && 1 === preg_match( self::ABSOLUTE_DATE, $value );
+	}
 }
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
index 0b2221db759..c7b75ab7520 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
@@ -985,7 +985,8 @@ class CheckoutFields {
 	/**
 	 * Applies type-specific arguments to a field before it is rendered with woocommerce_form_field().
 	 *
-	 * Used by the server-rendered My Account forms: maps select options and sets checkbox submit values.
+	 * Used by the server-rendered My Account forms: maps select options, sets checkbox submit values, and
+	 * resolves date min/max constraints into input attributes.
 	 *
 	 * @param array $form_field The woocommerce_form_field() arguments built from the field.
 	 * @return array The updated arguments.
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldTypeTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldTypeTest.php
new file mode 100644
index 00000000000..7857413185c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldTypeTest.php
@@ -0,0 +1,467 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Domain\Services\CheckoutFieldTypes;
+
+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes\DateFieldType;
+use WP_Error;
+use WP_UnitTestCase;
+
+/**
+ * Tests for the DateFieldType class.
+ */
+class DateFieldTypeTest extends WP_UnitTestCase {
+	/**
+	 * The system under test.
+	 *
+	 * @var DateFieldType
+	 */
+	private $field_type;
+
+	/**
+	 * Setup test case.
+	 */
+	protected function setUp(): void {
+		parent::setUp();
+		$this->field_type = new DateFieldType();
+	}
+
+	/**
+	 * @testdox Absolute dates and ISO 8601-2 durations resolve to the expected dates.
+	 *
+	 * @testWith ["2025-08-26", "2025-08-26"]
+	 *           ["P0D", "today"]
+	 *           ["P1D", "+1 day"]
+	 *           ["-P5D", "-5 days"]
+	 *           ["P2W", "+14 days"]
+	 *           ["-P18Y", "-18 years"]
+	 *           ["P1Y2M3D", "+1 year +2 months +3 days"]
+	 *
+	 * @param string $constraint The constraint to resolve.
+	 * @param string $equivalent A date expression resolving to the same day.
+	 */
+	public function test_supported_date_constraint_vocabulary( string $constraint, string $equivalent ) {
+		$form_field = $this->field_type->prepare_form_field( $this->date_field( array( 'min' => $constraint ) ) );
+
+		$this->assertSame( $this->date_relative_to_today( $equivalent ), $form_field['custom_attributes']['min'] );
+	}
+
+	/**
+	 * @testdox Invalid constraints fail registration with an error saying why they were rejected.
+	 *
+	 * @testWith ["min", "2026-02-31", "real calendar date"]
+	 *           ["max", "2026-02-31", "real calendar date"]
+	 *           ["min", "2026-8-6", "ISO 8601-2 duration"]
+	 *           ["min", "PT1H", "time component"]
+	 *           ["min", "P1DT2H", "time component"]
+	 *           ["max", "garbage", "ISO 8601-2 duration"]
+	 *           ["min", "--P1D", "ISO 8601-2 duration"]
+	 *           ["min", "today", "ISO 8601-2 duration"]
+	 *           ["min", "+1 day", "ISO 8601-2 duration"]
+	 *           ["min", "P", "ISO 8601-2 duration"]
+	 *           ["max", "", "ISO 8601-2 duration"]
+	 *
+	 * @param string $key        The constraint being set.
+	 * @param string $constraint The invalid value.
+	 * @param string $expected   A phrase expected in the registration error.
+	 */
+	public function test_invalid_constraints_are_registration_errors( string $key, string $constraint, string $expected ) {
+		$this->setExpectedIncorrectUsage( 'woocommerce_register_additional_checkout_field' );
+
+		$message = null;
+		add_action(
+			'doing_it_wrong_run',
+			function ( $function_name, $function_message ) use ( &$message ) {
+				// Avoid parameter-not-used PHPCS errors.
+				unset( $function_name );
+				$message = $function_message;
+			},
+			10,
+			2
+		);
+
+		$this->assertFalse( $this->register( array( $key => $constraint ) ) );
+		$this->assertStringContainsString( $expected, (string) $message );
+	}
+
+	/**
+	 * @testdox A constraint that is not a string fails registration.
+	 */
+	public function test_registration_rejects_non_string_constraints() {
+		$this->setExpectedIncorrectUsage( 'woocommerce_register_additional_checkout_field' );
+
+		$this->assertFalse( $this->register( array( 'min' => 20260826 ) ) );
+		$this->assertFalse( $this->register( array( 'max' => new \DateInterval( 'P1D' ) ) ) );
+	}
+
+	/**
+	 * @testdox Registration trims a duration and drops an explicit plus sign.
+	 */
+	public function test_registration_normalizes_constraints() {
+		$this->assertSame( 'P2W', $this->register( array( 'min' => ' +P2W ' ) )['min'] );
+	}
+
+	/**
+	 * @testdox Durations are stored on the field as registered, not resolved.
+	 */
+	public function test_constraints_are_stored_unresolved() {
+		// A resolved value here would freeze into any page cache holding the rendered form.
+		$field_data = $this->register(
+			array(
+				'min' => 'P0D',
+				'max' => 'P30D',
+			)
+		);
+
+		$this->assertSame( 'P0D', $field_data['min'] );
+		$this->assertSame( 'P30D', $field_data['max'] );
+	}
+
+	/**
+	 * @testdox A field registered without constraints carries no min or max key.
+	 */
+	public function test_missing_constraints_are_dropped() {
+		$field_data = $this->register( array() );
+
+		$this->assertArrayNotHasKey( 'min', $field_data );
+		$this->assertArrayNotHasKey( 'max', $field_data );
+	}
+
+	/**
+	 * @testdox An inverted range keeps the field, drops both limits, and logs only during value validation.
+	 *
+	 * @testWith ["2025-12-31", "2025-01-01"]
+	 *           ["P2M", "P1M"]
+	 *           ["-P13Y", "-P18Y"]
+	 *           ["P0D", "1900-01-01"]
+	 *           ["2999-12-31", "P0D"]
+	 *
+	 * @param string $min The min constraint.
+	 * @param string $max The max constraint.
+	 */
+	public function test_inverted_constraints_are_ignored( string $min, string $max ): void {
+		$warnings = 0;
+		$logger   = $this->createMock( \WC_Logger_Interface::class );
+		$logger->expects( $this->exactly( 2 ) )->method( 'warning' )->with(
+			$this->stringContains( 'Date limits for checkout field "test/date-of-birth" were ignored' ),
+			$this->arrayHasKey( 'source' )
+		)->willReturnCallback(
+			static function () use ( &$warnings ) {
+				++$warnings;
+			}
+		);
+		add_filter(
+			'woocommerce_logging_class',
+			static function () use ( $logger ) {
+				return $logger;
+			}
+		);
+
+		$field_data = $this->register(
+			array(
+				'min' => $min,
+				'max' => $max,
+			)
+		);
+		$this->assertIsArray( $field_data );
+		$this->assertSame( $min, $field_data['min'] );
+		$this->assertSame( $max, $field_data['max'] );
+		$field      = $this->date_field( $field_data );
+		$form_field = $this->field_type->prepare_form_field( $field );
+
+		$this->assertArrayNotHasKey( 'min', $form_field['custom_attributes'] );
+		$this->assertArrayNotHasKey( 'max', $form_field['custom_attributes'] );
+		$this->assertSame( 0, $warnings, 'Rendering the field should not log a warning.' );
+		$this->assertNull( $this->field_type->validate( '1800-01-01', $field ) );
+		$this->assertSame( 1, $warnings, 'Validating a submitted date should log one warning.' );
+		$this->assertNull( $this->field_type->validate( '3000-01-01', $field ) );
+		$this->assertInstanceOf( WP_Error::class, $this->field_type->validate( '2026-02-31', $field ) );
+	}
+
+	/**
+	 * @testdox Equal limits allow only that date.
+	 */
+	public function test_equal_constraints_allow_one_date(): void {
+		$field = $this->date_field(
+			array(
+				'min' => '2026-08-26',
+				'max' => '2026-08-26',
+			)
+		);
+
+		$this->assertNull( $this->field_type->validate( '2026-08-26', $field ) );
+		$this->assertInstanceOf( WP_Error::class, $this->field_type->validate( '2026-08-25', $field ) );
+		$this->assertInstanceOf( WP_Error::class, $this->field_type->validate( '2026-08-27', $field ) );
+	}
+
+	/**
+	 * @testdox An ordered min/max pair is accepted, as is a mixed absolute/duration pair.
+	 *
+	 * @testWith ["P1D", "P1M"]
+	 *           ["P28D", "P1M"]
+	 *           ["P12M", "P370D"]
+	 *           ["-P18Y", "-P13Y"]
+	 *           ["P0D", "2999-12-31"]
+	 *           ["2020-01-01", "P0D"]
+	 *
+	 * @param string $min The min constraint.
+	 * @param string $max The max constraint.
+	 */
+	public function test_ordered_constraints_are_accepted( string $min, string $max ) {
+		$this->assertIsArray(
+			$this->register(
+				array(
+					'min' => $min,
+					'max' => $max,
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox Month arithmetic clamps to the end of the target month, matching Temporal on the client.
+	 *
+	 * @testWith ["2026-01-31", "P1M", "2026-02-28"]
+	 *           ["2026-03-31", "-P1M", "2026-02-28"]
+	 *           ["2024-02-29", "P1Y", "2025-02-28"]
+	 *           ["2026-01-31", "P1M15D", "2026-03-15"]
+	 *           ["2026-08-26", "P2W", "2026-09-09"]
+	 *           ["2026-08-26", "P1W2D", "2026-09-04"]
+	 *           ["2026-08-26", "-P1W2D", "2026-08-17"]
+	 *           ["2026-08-26", "P1M2W", "2026-10-10"]
+	 *           ["2026-01-31", "P1M2W3D", "2026-03-17"]
+	 *
+	 * @param string $today      The current date in the store timezone.
+	 * @param string $constraint The constraint to resolve.
+	 * @param string $expected   The expected resolved date.
+	 */
+	public function test_month_arithmetic_clamps_like_temporal( string $today, string $constraint, string $expected ) {
+		// PHP's own DateInterval arithmetic would roll 31 January + P1M forward to 3 March and disagree
+		// with the date the browser offers in the picker. Resolution is relative to today, so the
+		// private resolver is called directly to pin the day the arithmetic starts from.
+		$resolve = new \ReflectionMethod( $this->field_type, 'resolve_constraint' );
+		$resolve->setAccessible( true );
+		$constraint = $this->register( array( 'min' => $constraint ) )['min'];
+
+		$this->assertSame( $expected, $resolve->invoke( $this->field_type, $constraint, new \DateTimeImmutable( $today, wp_timezone() ) ) );
+	}
+
+	/**
+	 * @testdox prepare_form_field exposes both resolved constraints as input attributes.
+	 */
+	public function test_prepare_form_field() {
+		$form_field = $this->field_type->prepare_form_field(
+			$this->date_field(
+				array(
+					'min' => 'P0D',
+					'max' => '2999-12-31',
+				)
+			)
+		);
+
+		$this->assertSame(
+			array(
+				'min' => $this->date_relative_to_today( 'today' ),
+				'max' => '2999-12-31',
+			),
+			$form_field['custom_attributes']
+		);
+	}
+
+	/**
+	 * @testdox An unconstrained field gets no min or max input attribute.
+	 */
+	public function test_prepare_form_field_without_constraints() {
+		$form_field = $this->field_type->prepare_form_field( $this->date_field() );
+
+		$this->assertSame( array(), $form_field['custom_attributes'] );
+	}
+
+	/**
+	 * @testdox Only a real calendar date in Y-m-d format is accepted.
+	 *
+	 * @testWith ["2025-08-26", false]
+	 *           ["2024-02-29", false]
+	 *           ["2026-02-29", true]
+	 *           ["", false]
+	 *           ["2025-02-31", true]
+	 *           ["2025-8-6", true]
+	 *           ["26-08-2025", true]
+	 *           ["not-a-date", true]
+	 *
+	 * @param string $value      The submitted value.
+	 * @param bool   $has_errors Whether the value should be rejected.
+	 */
+	public function test_only_real_calendar_dates_are_valid( string $value, bool $has_errors ) {
+		$this->assert_rejects( $has_errors, $value, $this->date_field() );
+	}
+
+	/**
+	 * @testdox Absolute date constraints are enforced, inclusive of both bounds.
+	 *
+	 * @testWith ["2025-01-01", false]
+	 *           ["2025-06-15", false]
+	 *           ["2025-12-31", false]
+	 *           ["2024-12-31", true]
+	 *           ["2026-01-01", true]
+	 *
+	 * @param string $value      The submitted value.
+	 * @param bool   $has_errors Whether the value should be rejected.
+	 */
+	public function test_absolute_date_constraints_are_enforced( string $value, bool $has_errors ) {
+		$this->assert_rejects(
+			$has_errors,
+			$value,
+			$this->date_field(
+				array(
+					'min' => '2025-01-01',
+					'max' => '2025-12-31',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox Relative date constraints are enforced against the current date.
+	 *
+	 * @testWith ["today", false]
+	 *           ["+1 day", false]
+	 *           ["+30 days", false]
+	 *           ["-1 day", true]
+	 *           ["+31 days", true]
+	 *
+	 * @param string $offset     The submitted value, relative to today.
+	 * @param bool   $has_errors Whether the value should be rejected.
+	 */
+	public function test_relative_date_constraints_are_enforced( string $offset, bool $has_errors ) {
+		$this->assert_rejects(
+			$has_errors,
+			$this->date_relative_to_today( $offset ),
+			$this->date_field(
+				array(
+					'min' => 'P0D',
+					'max' => 'P30D',
+				)
+			)
+		);
+	}
+
+	/**
+	 * @testdox A field without constraints accepts any real calendar date.
+	 */
+	public function test_unconstrained_fields_have_no_bounds() {
+		$this->assert_rejects( false, '1901-01-01', $this->date_field() );
+		$this->assert_rejects( false, '2222-12-31', $this->date_field() );
+	}
+
+	/**
+	 * @testdox An out of range date is rejected with a message naming the boundary in the site date format.
+	 */
+	public function test_out_of_range_date_error_message() {
+		update_option( 'date_format', 'F j, Y' );
+
+		$field = $this->date_field(
+			array(
+				'min' => '2025-01-01',
+				'max' => '2025-12-31',
+			)
+		);
+
+		$this->assertSame(
+			'Please provide a Promotion date on or after January 1, 2025.',
+			$this->field_type->validate( '2024-12-31', $field )->get_error_message()
+		);
+		$this->assertSame(
+			'Please provide a Promotion date on or before December 31, 2025.',
+			$this->field_type->validate( '2026-01-01', $field )->get_error_message()
+		);
+	}
+
+	/**
+	 * @testdox Values are displayed using the site date format, in the site timezone.
+	 *
+	 * @testWith ["UTC", "F j, Y", "August 26, 2025"]
+	 *           ["America/New_York", "Y-m-d", "2025-08-26"]
+	 *           ["Pacific/Auckland", "Y-m-d", "2025-08-26"]
+	 *
+	 * @param string $timezone    The site timezone.
+	 * @param string $date_format The site date format.
+	 * @param string $expected    The expected formatted value.
+	 */
+	public function test_value_formatting( string $timezone, string $date_format, string $expected ) {
+		update_option( 'timezone_string', $timezone );
+		update_option( 'date_format', $date_format );
+
+		$this->assertSame( $expected, $this->field_type->format_value( '2025-08-26', $this->date_field() ), 'The stored calendar date should never shift when it is formatted.' );
+	}
+
+	/**
+	 * @testdox Values that are not a real calendar date are displayed as stored.
+	 *
+	 * @testWith ["2025-02-31"]
+	 *           ["2025-13-01"]
+	 *           ["not-a-date"]
+	 *           [""]
+	 *
+	 * @param string $value The stored value.
+	 */
+	public function test_invalid_value_is_not_reformatted( string $value ) {
+		update_option( 'date_format', 'F j, Y' );
+
+		$this->assertSame( $value, $this->field_type->format_value( $value, $this->date_field() ), 'A value that is not a real date should be shown as stored rather than rolled forward.' );
+	}
+
+	/**
+	 * Asserts whether validating a value against a field produces an error.
+	 *
+	 * @param bool   $has_errors Whether the value should be rejected.
+	 * @param string $value      The submitted value.
+	 * @param array  $field      The field to validate against.
+	 */
+	private function assert_rejects( bool $has_errors, string $value, array $field ) {
+		$error = $this->field_type->validate( $value, $field );
+
+		$this->assertSame( $has_errors, $error instanceof WP_Error, sprintf( 'Unexpected validation result for "%s".', $value ) );
+	}
+
+	/**
+	 * Returns a registered date field, with the given constraints applied.
+	 *
+	 * @param array $constraints The min and/or max to apply.
+	 * @return array
+	 */
+	private function date_field( array $constraints = array() ): array {
+		return array_merge(
+			array(
+				'type'  => 'date',
+				'label' => 'Promotion date',
+			),
+			$constraints
+		);
+	}
+
+	/**
+	 * Processes registration options through the field type, as field registration does.
+	 *
+	 * @param array $options The options supplied during field registration.
+	 * @return array|false The processed field data, or false if an error should prevent registration.
+	 */
+	private function register( array $options ) {
+		$field_data = array(
+			'id'         => 'test/date-of-birth',
+			'attributes' => array(),
+		);
+
+		return $this->field_type->process_options( $field_data, array_merge( array( 'id' => $field_data['id'] ), $options ) );
+	}
+
+	/**
+	 * Returns a Y-m-d date, resolved independently of the code under test.
+	 *
+	 * @param string $expression An absolute Y-m-d date, or an expression relative to today such as "+1 day".
+	 * @return string
+	 */
+	private function date_relative_to_today( string $expression ): string {
+		return ( new \DateTime( $expression, wp_timezone() ) )->format( 'Y-m-d' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
index e04e80cdc94..4c8e90bb850 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Blocks\Domain\Services;

 use Automattic\WooCommerce\Blocks\Package;
 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsAdmin;
 use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject;
 use WP_UnitTestCase;

@@ -97,6 +98,14 @@ class CheckoutFieldsTest extends WP_UnitTestCase {
 				'location' => 'order',
 				'type'     => 'date',
 			),
+			array(
+				'id'       => 'plugin-namespace/appointment-date',
+				'label'    => 'Appointment date',
+				'location' => 'order',
+				'type'     => 'date',
+				'min'      => 'P0D',
+				'max'      => 'P30D',
+			),
 			array(
 				'id'         => 'namespace/vat-number',
 				'label'      => 'VAT Number',
@@ -196,35 +205,38 @@ class CheckoutFieldsTest extends WP_UnitTestCase {
 	}

 	/**
-	 * @testdox Date fields can be registered.
+	 * @testdox Date fields can be registered, with their constraints stored as registered.
 	 */
 	public function test_date_fields_can_be_registered() {
 		$fields = $this->controller->get_additional_fields();

 		$this->assertArrayHasKey( 'plugin-namespace/delivery-date', $fields, 'Date fields should be a supported field type.' );
 		$this->assertSame( 'date', $fields['plugin-namespace/delivery-date']['type'] );
+
+		// The constraint rules themselves are covered by DateFieldTypeTest; this only checks registration carries them through unresolved.
+		$this->assertSame( 'P0D', $fields['plugin-namespace/appointment-date']['min'] );
+		$this->assertSame( 'P30D', $fields['plugin-namespace/appointment-date']['max'] );
 	}

 	/**
-	 * @testdox Date fields only accept a real calendar date in Y-m-d format.
-	 *
-	 * @testWith ["2026-08-26", false]
-	 *           ["2024-02-29", false]
-	 *           ["", false]
-	 *           ["2026-02-31", true]
-	 *           ["2026-02-29", true]
-	 *           ["2026-8-6", true]
-	 *           ["26-08-2026", true]
-	 *           ["not-a-date", true]
-	 *
-	 * @param string $value       The submitted value.
-	 * @param bool   $has_errors  Whether the value should be rejected.
+	 * @testdox Old order dates can be edited without the current checkout limits.
 	 */
-	public function test_date_field_validation( string $value, bool $has_errors ) {
-		$fields = $this->controller->get_additional_fields();
-		$errors = $this->controller->validate_field( $fields['plugin-namespace/delivery-date'], $value );
-
-		$this->assertSame( $has_errors, $errors->has_errors(), sprintf( 'Unexpected validation result for "%s".', $value ) );
+	public function test_order_editor_omits_date_constraints(): void {
+		$sut   = Package::container()->get( CheckoutFieldsAdmin::class );
+		$order = new \WC_Order();
+		$key   = '_wc_other/plugin-namespace/appointment-date';
+		$order->set_created_via( 'store-api' );
+		$order->update_meta_data( $key, '1900-01-01' );
+
+		$fields = $sut->admin_order_fields( array(), $order );
+		$field  = $fields['plugin-namespace/appointment-date'];
+
+		$this->assertSame( '1900-01-01', $field['value'] );
+		$this->assertArrayNotHasKey( 'min', $field['custom_attributes'] ?? array() );
+		$this->assertArrayNotHasKey( 'max', $field['custom_attributes'] ?? array() );
+
+		$sut->update_callback( $key, '1900-01-02', $order );
+		$this->assertSame( '1900-01-02', $order->get_meta( $key ) );
 	}

 	/**
@@ -271,46 +283,32 @@ class CheckoutFieldsTest extends WP_UnitTestCase {
 	}

 	/**
-	 * @testdox Date field values are displayed using the site date format, in the site timezone.
-	 *
-	 * @testWith ["UTC", "F j, Y", "August 26, 2026"]
-	 *           ["America/New_York", "Y-m-d", "2026-08-26"]
-	 *           ["Pacific/Auckland", "Y-m-d", "2026-08-26"]
-	 *
-	 * @param string $timezone    The site timezone.
-	 * @param string $date_format The site date format.
-	 * @param string $expected    The expected formatted value.
+	 * @testdox Date field validation is delegated to the date field type.
 	 */
-	public function test_date_field_value_formatting( string $timezone, string $date_format, string $expected ) {
-		update_option( 'timezone_string', $timezone );
-		update_option( 'date_format', $date_format );
-
-		$fields = $this->controller->get_additional_fields();
-		$value  = $this->controller->format_additional_field_value( '2026-08-26', $fields['plugin-namespace/delivery-date'] );
+	public function test_date_field_validation_is_delegated() {
+		$field = $this->controller->get_additional_fields()['plugin-namespace/delivery-date'];

-		$this->assertSame( $expected, $value, 'The stored calendar date should never shift when it is formatted.' );
+		$this->assertFalse( $this->controller->validate_field( $field, '2025-08-26' )->has_errors() );
+		$this->assertTrue( $this->controller->validate_field( $field, '2025-02-31' )->has_errors() );
 	}

 	/**
-	 * @testdox Date values that are not a real calendar date are displayed as stored.
-	 *
-	 * @testWith ["2026-02-31"]
-	 *           ["2026-13-01"]
-	 *           ["not-a-date"]
-	 *           [""]
-	 *
-	 * @param string $value The stored value.
+	 * @testdox A date field whose constraints the date field type rejects is not registered.
 	 */
-	public function test_invalid_date_field_value_is_not_reformatted( string $value ) {
-		update_option( 'date_format', 'F j, Y' );
-
-		$fields = $this->controller->get_additional_fields();
+	public function test_date_field_with_invalid_constraint_is_not_registered() {
+		$this->setExpectedIncorrectUsage( 'woocommerce_register_additional_checkout_field' );

-		$this->assertSame(
-			$value,
-			$this->controller->format_additional_field_value( $value, $fields['plugin-namespace/delivery-date'] ),
-			'A value that is not a real date should be shown as stored rather than rolled forward.'
+		woocommerce_register_additional_checkout_field(
+			array(
+				'id'       => 'plugin-namespace/invalid-constraint',
+				'label'    => 'Invalid constraint',
+				'location' => 'order',
+				'type'     => 'date',
+				'min'      => 'not-a-date',
+			)
 		);
+
+		$this->assertArrayNotHasKey( 'plugin-namespace/invalid-constraint', $this->controller->get_additional_fields() );
 	}

 	/**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 608655b220f..4122aee3bbc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3367,6 +3367,9 @@ importers:
       react-transition-group:
         specifier: ^4.4.5
         version: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      temporal-polyfill:
+        specifier: 1.0.4
+        version: 1.0.4
       trim-html:
         specifier: 0.1.9
         version: 0.1.9
@@ -18424,6 +18427,15 @@ packages:
     resolution: {integrity: sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==}
     engines: {node: '>=6.0.0'}

+  temporal-polyfill@1.0.4:
+    resolution: {integrity: sha512-MLEU0qOD2uXlz24oINNtdLZQl8RgmMxSnRtKEGZGGetTJjlRwQJjk+VsJ4EREaUoiaTb8NJOcUiKtoAiWn9EBg==, tarball: https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-1.0.4.tgz}
+
+  temporal-spec@1.0.1:
+    resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==, tarball: https://registry.npmjs.org/temporal-spec/-/temporal-spec-1.0.1.tgz}
+
+  temporal-utils@1.0.2:
+    resolution: {integrity: sha512-1B8Dl4KzrOvsNUlpoWGno2VLQlxroLjDgc5NBjVC9ax9ymdo+ezfyvhyjEMx+IVfhINr6CIG2gcnlP95iRrybQ==, tarball: https://registry.npmjs.org/temporal-utils/-/temporal-utils-1.0.2.tgz}
+
   term-size@1.2.0:
     resolution: {integrity: sha512-7dPUZQGy/+m3/wjVz3ZW5dobSoD/02NxJpoXUX0WIyjfVS3l0c+b/+9phIDFA7FHzkYtwtMFgeGZ/Y8jVTeqQQ==}
     engines: {node: '>=4'}
@@ -41666,6 +41678,15 @@ snapshots:
     dependencies:
       rimraf: 2.6.3

+  temporal-polyfill@1.0.4:
+    dependencies:
+      temporal-spec: 1.0.1
+      temporal-utils: 1.0.2
+
+  temporal-spec@1.0.1: {}
+
+  temporal-utils@1.0.2: {}
+
   term-size@1.2.0:
     dependencies:
       execa: 0.7.0