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. 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.

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, replace one call with your own service (Loqate, VIES, a dedup lookup), and you have a real bridge. 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. Every getter below is fair game for a validator - use any subset you care about. 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 - VAT/tax number, the natural target for VIES-style checks
getPhone() phone Nullable
getStreet() street Nullable - single flattened line
getCity() city Nullable
getRegion() region Nullable
getPostcode() postcode Nullable - what the example bridge validates
getCountryCode() country Nullable - ISO 3166-1 alpha-2. Note the key: the built-in address validator maps this getter to the country input
getWebsiteId() - Scope for lookups (dedup per website), not a form field
getCustomerId() - 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.

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 [] - never a hard block, never an exception. An uncaught exception propagates out of the pool and fails the whole customer-create call with a 500.

Contract Rules

  1. Every validator runs, results merge. The pool (Hyva\Pos\Model\Customer\CustomerValidatorPool) does not short-circuit on the first result. It concatenates every validator's rows with array_merge, so ordering never decides a winner and a refining validator is order-agnostic.
  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. This is the rule the conformance kit pins hardest.

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 survives 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>

The example uses xsi:type="object" because it has no third-party vendor dependency. A bridge that wraps a vendor module should use xsi:type="string" (the class name) instead: the pool resolves string entries lazily inside a try/catch, so your bridge is skipped rather than crashing DI when the vendor is missing.

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, and the cashier gets a one-tap fix. 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 draft's own past, 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; the never-throw check is the one that saves you in production. 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 dies. 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.