---
title: "Cross-attribute validation — Attributes for Laravel | Roundly"
description: "Validate an owner’s whole attribute set in one pass, so rules like gte:min_price or required_if:kind,physical see the other attributes."
url: https://roundly-consulting.com/open-source/docs/attributes-for-laravel/cross-attribute-validation
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Attributes for Laravel](https://roundly-consulting.com/open-source/docs/attributes-for-laravel.md)

# Cross-attribute validation

By default each value is validated against its own definition alone, so a rule that names another attribute — gte:min\_price, required\_if:kind,physical — can’t see it and fails. Whole-set validation checks the set the owner will hold in one Validator pass instead:

```php
use RoundlyConsulting\Attributes\DataTransferObjects\AttributeDefinitionData;
use RoundlyConsulting\Attributes\Enums\AttributeType;
use RoundlyConsulting\Attributes\Facades\Attributes;

Attributes::defineMany(
    new AttributeDefinitionData('min_price', AttributeType::Integer, ['lte:max_price']),
    new AttributeDefinitionData('max_price', AttributeType::Integer, ['gte:min_price']),
    new AttributeDefinitionData('kind', AttributeType::String_, ['in:physical,digital']),
    new AttributeDefinitionData('weight', AttributeType::Float_, ['required_if:kind,physical']),
);
```

## setValidated()

Attributes::for($owner)->setValidated(\[...\]) takes the owner’s stored values — read fresh, encrypted ones decrypted — overlays the ones you write, validates the result as one set and then writes all or nothing. It takes the same per-name meta map as setMany() and returns the written Attribute models:

```php
use RoundlyConsulting\Attributes\Facades\Attributes;

Attributes::for($product)->setValidated(['min_price' => 10, 'max_price' => 20]);
Attributes::for($product)->setValidated(['max_price' => 5]);     // throws: min_price must be ≤ 5

Attributes::for($product)->setValidated(['kind' => 'physical']); // throws: weight is required
Attributes::for($product)->setValidated(['kind' => 'physical', 'weight' => 1.5]); // fine

// The per-value path can't see the other attribute, so it fails the same rules
Attributes::for($product)->setMany(['min_price' => 1, 'max_price' => 5]); // throws
```

## How the set is validated

- Every definition’s rules run, led by nullable and the type’s rule — also for a defined name that isn’t attached, so implicit rules (required\_if, required\_with) fire.
- A null value passes every rule but the implicit ones, as on the per-value path. Free-form names carry no rules but can be referred to.
- Untouched attributes are validated too: a write that breaks another attribute’s rule fails, and a stored value that is already invalid blocks writes until you fix it.
- Strict mode and unique scopes apply to the written names as usual; the owner’s own schema counts like everywhere else.
- If anything fails, nothing is written.

Rules behave exactly as in a form request — gte:min\_price fails while min\_price is missing. Allow a lone value with exclude\_without:

```php
// gte:min_price fails while min_price is missing — as in a form request.
// Allow a lone bound with exclude_without:
Attributes::defineMany(
    new AttributeDefinitionData('min_price', AttributeType::Integer, ['exclude_without:max_price', 'lte:max_price']),
    new AttributeDefinitionData('max_price', AttributeType::Integer, ['exclude_without:min_price', 'gte:min_price']),
);

Attributes::for($product)->setValidated(['max_price' => 3]); // fine

// A name with a dot is taken literally; escape it in a rule that refers to it
new AttributeDefinitionData('size.height', AttributeType::Integer, ['lte:size\.width']);
```

## Errors

A failed set throws InvalidAttributeValueException with an error bag keyed by attribute name:

```php
use RoundlyConsulting\Attributes\Exceptions\InvalidAttributeValueException;

try {
    Attributes::for($product)->setValidated(['max_price' => 5]);
} catch (InvalidAttributeValueException $e) {
    $e->attributeName;       // 'min_price' — the first failing name, in definition order
    $e->errorBag()->keys();  // ['min_price', 'max_price'] — keyed by attribute name
    $e->messages();          // ['min_price: The min_price field must be less than or equal to 5.', ...]
}
```

## Concurrent writes

The write runs in one transaction with the owner’s row locked (SELECT … FOR UPDATE by primary key), so two whole-set writes of one owner run one after another and the second validates the set the first left — two people saving the same record can’t jointly create a combination neither was allowed to save. When the attribute model lives on another connection than the owner, the owner’s connection opens its own transaction to hold the lock.

- Writes on the per-value path take no owner lock, so mixing both paths on one owner isn’t serialized — turn on validation.whole\_set to route every write through the lock.
- On MySQL (REPEATABLE READ), don’t read the owner’s attributes earlier in an outer transaction you wrap the write in — the snapshot would predate the lock.

## For every write

Set validation.whole\_set to route the everyday writers through the same path. It is off by default:

```bash
# .env — every value write validates the owner's whole set
ATTRIBUTES_VALIDATE_WHOLE_SET=true
```

```php
// With validation.whole_set on, the everyday writers validate the whole set too
Attributes::for($product)->set('max_price', 30);
Attributes::for($product)->setMany(['min_price' => 5, 'max_price' => 8]);
$product->attributes()->set('kind', 'digital')->save();
Attributes::for($product)->sync(['kind' => 'digital']);  // validates the new set alone

$product->validateAttributes(); // the stored set, as one
```

set(), setMany(), stage()->save() and sync() then validate as setValidated() does — a sync validates the given set alone, because the rest is detached, and detaches nothing when it fails. meta() keeps its per-name path, since it leaves the value alone. validateAttributes() still reports a missing required attribute first, then validates the stored set as one.

## Validate without writing

Attributes::validateSet($owner, $values) validates a complete set and writes nothing — for a form that should report errors before you save:

```php
use RoundlyConsulting\Attributes\Facades\Attributes;

// Validate a complete set without writing — e.g. a form's input over the stored values
Attributes::validateSet($product, [
    ...Attributes::for($product)->toKeyValue(),
    ...$request->input('attributes', []),
]);
```

Pass the complete set: validateSet() doesn’t merge the stored values for you.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/attributes-for-laravel.md)
