NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Attributes for Laravel

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:

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:

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:

// 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:

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:

# .env — every value write validates the owner's whole set
ATTRIBUTES_VALIDATE_WHOLE_SET=true
// 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:

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

By donating, you agree to our donation terms.

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.