Skip to content

Customer Validators

Hyvä POS is in closed beta

Hyvä POS is currently in a closed beta (pilot phase) with a small group of merchants. It is not yet generally available: the App Store release follows the pilot, and features and configuration may still change, possibly in backwards-incompatible ways, before the general release. Want to take part? Sign up at hyva.io/pos.

When a cashier creates or edits a customer at the register, the draft is validated server-side: address plausibility, VAT-number checks, email deliverability, deduplication. The same pool runs when a cashier types a shipping address for a walk-in customer at checkout: the request then carries the recipient's name, email, phone, company and address with no customer id, and the register ignores an email_already_exists finding for it, since a guest order does not become an account order. Your validator plugs into that flow through one interface. Unlike the winner-take-all provider pools, the customer validator pool runs every registered validator and merges their rows into one flat list, so the cashier sees every problem in a single round-trip instead of fixing them one save at a time.

For a validator on a real third-party service, see the Geissweb EU VAT bridge, which checks VAT numbers against VIES. This page walks through hyva-themes/magento2-hyva-pos-example-validator-bridge, a complete, tested proof-of-concept validator that checks a postcode against its country. Copy its shape and replace one call with your own service (Loqate, VIES, a dedup lookup). Module scaffolding (registration guard, install, verification) is covered once in Building a Bridge and not repeated here.

The Interface

One method:

Hyva\Pos\Api\CustomerValidatorInterface
interface CustomerValidatorInterface
{
    /**
     * Validate the draft customer and return zero or more error rows.
     *
     * @param \Hyva\Pos\Api\Data\Customer\ValidationRequestInterface $request
     * @return \Hyva\Pos\Api\Data\Customer\ValidationErrorInterface[] Indexed list.
     */
    public function validate(\Hyva\Pos\Api\Data\Customer\ValidationRequestInterface $request): array;
}

The request carries the full customer draft. A validator can use any subset of the getters below. The field key column is what you put in the error row's field so the app marks the matching form input; the convention is the getter's property name verbatim:

Request getter Field key Notes
getEmail() email Never empty (required-fields runs first, but do not rely on order)
getFirstname() firstname
getLastname() lastname
getCompany() company Nullable
getTaxvat() taxvat Nullable: the Tax/VAT number on the customer account
getPhone() phone Nullable: the address's telephone
getStreet() street Nullable: single flattened line
getCity() city Nullable
getRegion() region Nullable
getPostcode() postcode Nullable: what the example bridge validates
getCountryCode() countryCode Nullable: ISO 3166-1 alpha-2
getVatId() vatId Nullable: the VAT number on the address
getFax() fax Nullable: the address's fax
getWebsiteId() (none) Scope for lookups (dedup per website), not a form field
getLocationId() (none) The POS location the register sells at, 0 when none is stated; it decides which validators run, not a form field
getCustomerId() (none) Null on create, set in update mode (below), not a form field

Two rows in one error list can target the same field key; the app stacks them on the input in list order.

Two VAT Numbers

Magento keeps a Tax/VAT number on the customer account (taxvat) and a VAT number on each address (vat_id), and the request carries both. They are separate fields at the counter too: the register prefills its address VAT Number input from the account's number when that is set, and sends whatever the cashier left there. Nothing is copied between them server-side.

A validator that checks a VAT number says which of the two it checked, by reporting on the taxvat or the vatId field key. When both are filled and they differ, check both; when only one is filled, checking the other says nothing about the draft. The built-in TaxvatValidator runs its format rule on each, with taxvat_* and vat_id_* codes.

Each returned row is a ValidationErrorInterface. The POS reads field to attach the message to the right form input, severity to pick the visual style, and suggestedValue to populate a suggestion chip:

Method Meaning
getField() Form field the row applies to (e.g. postcode, taxvat, email)
getCode() Stable machine-readable token (e.g. postcode_invalid_for_country). The POS may fall back to a localized default message for an unknown code
getSeverity() One of the four constants below
getMessage() Localized message text shown to the cashier
getSuggestedValue() Replacement value, required when severity is normalize

The four severity constants on ValidationErrorInterface decide what the register does:

  • SEVERITY_ERROR (error): save is blocked until the cashier fixes the field.
  • SEVERITY_WARNING (warning): save is still allowed; the cashier sees the warning.
  • SEVERITY_INFO (info): soft hint, no action required.
  • SEVERITY_NORMALIZE (normalize): pair with suggestedValue so the client can offer a "Use suggestion" chip that overwrites the field on accept.

validate() must never throw. A network validator (postcode lookup, VAT check, email deliverability) must catch its own failures and degrade to a warning row ("Could not verify the address right now") or to [], not a hard block and not an exception. An uncaught exception propagates out of the pool and fails the whole customer-create call with a 500.

Contract Rules

  1. Every enabled validator runs, results merge. The pool (Hyva\Pos\Model\Customer\CustomerValidatorPool) does not short-circuit on the first result. It concatenates every enabled validator's rows with array_merge, so ordering never decides a winner and a refining validator is order-agnostic. Which validators are enabled is the merchant's Customer Validators setting (Stores > Configuration > Hyvä POS > Terminal Defaults > Customers, website scope), and from module 1.2.0 a location may override it: Hyvä POS > Store Setup > Locations, where a location either uses the default, runs every installed validator, runs a selection of its own, or runs no optional validators at all. Empty, the default, runs every registered validator, so your bridge runs as soon as it is installed. A selection runs only the selected validators next to required_fields and email_uniqueness, which always run. Your validator is listed under its di.xml key; the di.xml section shows how to give it a label.
  2. Return a plain indexed list. Even for a single row. A string-keyed map corrupts the merge. Return [] when the draft passes.
  3. Refine, do not replace. The built-in validators ship under low-priority pool keys (required_fields, email_uniqueness, taxvat, address) and run first. Your validator adds checks alongside them. The example shows the pattern: the built-in AddressValidator flags a missing postcode as a warning; the example flags a present-but-invalid postcode as an error, using the same postcode field key so both rows attach to the same input on the register.
  4. Run-twice safe. The result feeds both the dedicated validate endpoint (live feedback in the form) and the create endpoint (the authoritative check on save), so your validator may run twice for one customer. It must be deterministic and side-effect-free: do not write, count, or rate-limit inside validate().
  5. Never throw. See above. The conformance kit pins this rule.

Build It

The example bridge is five source files plus tests. Each file below is the real one, trimmed.

composer.json

Depend on the POS module (the interface and DTOs) and whatever your check needs. The example uses Magento's Directory module; a real bridge swaps that for its address-service SDK.

composer.json
{
    "name": "hyva-themes/magento2-hyva-pos-example-validator-bridge",
    "type": "magento2-module",
    "require": {
        "php": ">=8.1",
        "hyva-themes/magento2-hyva-pos": "*",
        "magento/module-directory": "*"
    },
    "autoload": {
        "files": ["src/registration.php"],
        "psr-4": { "Hyva\\PosExampleValidatorBridge\\": "src/" }
    }
}

registration.php and module.xml

The example registers unconditionally because it depends only on Magento core. If your validator constructor-injects a third-party vendor's typed classes, add the registration guard from Building a Bridge so setup:di:compile succeeds when that vendor is absent. In module.xml, sequence after Hyva_Pos (so the pool exists) and after whatever module wires your backing service.

di.xml

Add your validator to the pool. The argument name is validators:

src/etc/di.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Hyva\Pos\Model\Customer\CustomerValidatorPool">
        <arguments>
            <argument name="validators" xsi:type="array">
                <item name="example_postcode_country" xsi:type="object">Hyva\PosExampleValidatorBridge\Model\Customer\Validator\ExamplePostcodeCountryValidator</item>
            </argument>
        </arguments>
    </type>
</config>

Your validator is selectable in the merchant's Customer Validators setting under its item name, shown with underscores as spaces (example_postcode_country reads as "Example postcode country"). To show a proper label instead, add the same key to the labels argument of the option source:

src/etc/di.xml
<type name="Hyva\Pos\Model\Source\CustomerValidators">
    <arguments>
        <argument name="labels" xsi:type="array">
            <item name="example_postcode_country" xsi:type="string">Postcode matches country</item>
        </argument>
    </arguments>
</type>

Use xsi:type="object" for this pool. Unlike the credit provider pool, CustomerValidatorPool does not resolve string entries lazily: it skips anything that is not already a CustomerValidatorInterface instance, so a string entry is silently ignored. If your validator constructor-injects a third-party vendor's classes, the registration guard from Building a Bridge keeps the object entry safe: when the vendor is absent, the whole bridge deregisters and the entry never instantiates.

The Validator

The whole example, minus the didactic comments:

src/Model/Customer/Validator/ExamplePostcodeCountryValidator.php
class ExamplePostcodeCountryValidator implements CustomerValidatorInterface
{
    private const FIELD_POSTCODE = 'postcode';
    private const CODE_POSTCODE_INVALID = 'postcode_invalid_for_country';

    public function __construct(
        private readonly PostcodeValidatorInterface $postcodeValidator,
        private readonly ValidationErrorInterfaceFactory $errorFactory,
        private readonly LoggerInterface $logger
    ) {
    }

    public function validate(ValidationRequestInterface $request): array
    {
        $postcode = trim((string) ($request->getPostcode() ?? ''));
        $countryId = trim((string) ($request->getCountryCode() ?? ''));

        // Only validate when BOTH are present. Required-ness is the built-in
        // validators' concern, not ours.
        if ($postcode === '' || $countryId === '') {
            return [];
        }

        try {
            $isValid = $this->postcodeValidator->validate($postcode, $countryId);
        } catch (\Throwable $e) {
            // Never throw: degrade to [] on infrastructure failure.
            $this->logger->debug('Hyva_PosExampleValidatorBridge: postcode validation skipped', [
                'countryId' => $countryId,
                'exception' => $e->getMessage(),
            ]);
            return [];
        }

        if ($isValid) {
            return [];
        }

        $error = $this->errorFactory->create();
        $error->setField(self::FIELD_POSTCODE)
            ->setCode(self::CODE_POSTCODE_INVALID)
            ->setSeverity(ValidationErrorInterface::SEVERITY_ERROR)
            ->setMessage((string) __('Postcode "%1" is not valid for %2.', $postcode, $countryId));

        return [$error];
    }
}

To adapt it for a real service, replace the one $this->postcodeValidator->validate(...) call with your API call and keep the never-throw wrapper. When your service returns a canonical form, emit a normalize row with setSuggestedValue($canonical) instead of an error; the register offers the suggested value as a one-tap correction. When the remote check cannot run, return a warning row with a stable code like address_unverified so the cashier can proceed.

Update Mode

getCustomerId() on the request marks the draft as proposed edits to a customer that already exists (the cashier is changing an address, say). It is null for a new-customer draft. Any uniqueness-style validator, dedup services in particular, must exclude the customer named by customerId from its own-record check: a customer keeping their own email on an address edit is not a duplicate, while the same email on a different customer still returns an error. The built-in email_uniqueness validator follows this rule; yours must too.

Concretely, for a validator that checks the draft against an external dedup service:

src/Model/Customer/Validator/AcmeDedupValidator.php (excerpt)
public function validate(ValidationRequestInterface $request): array
{
    $matches = $this->dedupClient->findByEmail(
        $request->getEmail(),
        $request->getWebsiteId()
    );

    $editedCustomerId = $request->getCustomerId(); // null on create

    foreach ($matches as $match) {
        if ($editedCustomerId !== null && (int) $match->getCustomerId() === $editedCustomerId) {
            // The cashier is editing THIS customer - matching your own
            // record is not a duplicate. Skip it.
            continue;
        }
        $error = $this->errorFactory->create();
        $error->setField('email')
            ->setCode('acme_duplicate_email')
            ->setSeverity(ValidationErrorInterface::SEVERITY_ERROR)
            ->setMessage((string) __('A customer with this email already exists.'));
        return [$error];
    }
    return [];
}

The same exclusion applies to any own-record check: a loyalty-membership lookup, a phone-number dedup, a tax-number registry: in update mode, the record identified by customerId is the customer being edited, not a conflict.

Prove It

The core ships Hyva\Pos\TestFramework\Conformance\AbstractCustomerValidatorConformanceTest, which encodes the contract as PHPUnit assertions: validate() returns an indexed list, every row is a well-formed ValidationErrorInterface with a severity from the four constants, a normalize row carries a non-null suggestedValue, and validate() never throws when the backend fails. Extend it and supply only your wiring: two required methods, two opt-in hooks:

tests/unit/ExamplePostcodeCountryValidatorConformanceTest.php
class ExamplePostcodeCountryValidatorConformanceTest extends AbstractCustomerValidatorConformanceTest
{
    protected function createProvider(): CustomerValidatorInterface
    {
        // The validator wired to doubles whose default answer is "valid".
        $postcodeValidator = $this->getMockBuilder(PostcodeValidatorInterface::class)
            ->getMockForAbstractClass();
        $postcodeValidator->method('validate')->willReturn(true);

        return new ExamplePostcodeCountryValidator(
            $postcodeValidator,
            $this->makeErrorFactory(),
            $this->getMockBuilder(LoggerInterface::class)->getMockForAbstractClass()
        );
    }

    protected function validRequest(): ValidationRequestInterface
    {
        // A well-formed draft this validator passes cleanly.
        return (new ValidationRequest())->setPostcode('1234 AB')->setCountryCode('NL');
    }

    protected function arrangeErrorRows(): ?array
    {
        // Wire the double to reject, run a draft through validate(), and
        // return the rows. The kit checks their shape.
        // ... (build a validator whose postcode double returns false)
        return $validator->validate(
            (new ValidationRequest())->setPostcode('XXXX')->setCountryCode('NL')
        );
    }

    protected function arrangeInfrastructureFailure(): ?array
    {
        // Wire the double to throw, then return [validator, request]. The
        // kit calls validate() and asserts it does not throw.
        // ... (postcode double throws InvalidArgumentException)
        return [$validator, (new ValidationRequest())->setPostcode('1234 AB')->setCountryCode('ZZ')];
    }
}

The two arrange*() hooks default to null, and a null return skips that scenario instead of failing it, for a validator that genuinely cannot produce an error row in a unit test, or has no failable infrastructure. Override both if you can reach them. Run the suite from the module root with ../../vendor/bin/phpunit. Alongside the conformance test, add plain unit pins for your own behavior (the example pins the exact field, code, and severity of its one error row, and that a backend throw degrades to []).

Gotchas

  • A string-keyed map corrupts the merge. The pool flattens all results with array_merge; string keys collide and silently drop rows. Always return [] or a plain indexed list.
  • An uncaught exception is a 500 on customer create. Not just a failed validation: the entire create call fails. Wrap every external call in try/catch and degrade.
  • Your validator runs twice per customer. Once on the validate endpoint, once on create. Anything non-deterministic (rate limiting, counters, writes) misbehaves here.
  • Match the built-in field keys. Use the same field value the built-in validators use (postcode, email, taxvat, ...) so your row attaches to the same input the cashier is looking at.
  • normalize without suggestedValue is broken. The suggestion chip has nothing to offer. The conformance kit fails this.
  • Ignore customerId and edits break. A dedup or uniqueness validator that does not exclude the customer's own record blocks every address edit with a false duplicate.
  • Do not duplicate required-ness checks. The built-in required_fields and address validators own missing-field rows (including Magento's optional-zip countries logic). Skip empty inputs and only judge values that are present.