awobaz/eloquent-mutators
Add get and set mutators to Eloquent attributes without boilerplate accessor methods. Define reusable mutator classes and attach them to model attributes at runtime, keeping models clean and mutator logic reusable across your Laravel projects.
Install
composer require awobaz/eloquent-mutators
No service provider or config publish is involved — it is a trait plus a base class. Confirm on install:
ls vendor/awobaz/eloquent-mutators/src
Where to look first (in this order)
Mutation class — it defines the contract your mutators implement (getValue / setValue in some form) and tells you whether you receive the raw value only or the model instance too.Mutable trait — it tells you which Eloquent methods are overridden (getAttributeValue, setAttribute, mutateAttribute, etc.). This dictates how it composes with casts, accessors and dirty tracking.$mutations example, to confirm the property name and the class-resolution mechanism in your installed version.First use case
Pick a value that is currently duplicated as getFooAttribute/setFooAttribute in two or more models. A phone number, slug, currency amount, or "trim + lowercase email" is ideal. Define it once:
namespace App\Mutators;
use Awobaz\Mutator\Mutation;
class PhoneNumber extends Mutation
{
public function getValue($value)
{
return $value === null ? null : $this->format($value);
}
public function setValue($value)
{
return $value === null ? null : preg_replace('/\D+/', '', $value);
}
protected function format(string $value): string
{
return sprintf('+1 (%s) %s-%s',
substr($value, 0, 3), substr($value, 3, 3), substr($value, 6));
}
}
Attach it to a model:
use Awobaz\Mutator\Mutable;
class Customer extends Model
{
use Mutable;
protected $mutations = [
'phone' => \App\Mutators\PhoneNumber::class,
];
}
Verify in tinker before writing any feature tests:
$c = new Customer(['phone' => '(555) 123-4567']);
$c->phone; // formatted
$c->getAttributes()['phone']; // raw, stored form
If those two lines behave as expected, the trait is wired correctly and everything else is detail work.
Stateless mutator classes with constructor injection. The value object should hold no per-model state; anything it needs comes from the container. This lets you reuse one mutator across a dozen models and keeps it testable in isolation.
class Money extends Mutation
{
public function __construct(private PriceFormatter $formatter) {}
public function getValue($value) { return $this->formatter->format($value); }
public function setValue($value) { return $this->formatter->parse($value); }
}
Extract-then-share, never share-then-extract. Migrate one existing getFooAttribute into a Mutation class first, keep the behaviour byte-identical, ship it, then delete the duplicate accessors from the second model. Doing both at once makes a failing test ambiguous.
Mutators are for canonicalisation, casts are for typing. Let the mutator trim/normalise/encrypt and let $casts handle datetime, array, encrypted. Do not use a mutator to replace encrypted casting unless you need key rotation or a custom cipher — you will re-implement Laravel's double-encryption guard and get it wrong.
Pair every mutator with a query scope. This is the single biggest day-to-day pattern, because the query builder completely bypasses mutators:
public function scopeWherePhone($query, string $phone)
{
return $query->where('phone', preg_replace('/\D+/', '', $phone));
}
Without this, Customer::where('phone', $request->phone)->first() silently returns nothing — the stored value is normalised, the input is not.
Unit-test the mutator, feature-test the model. Mutation is a plain class, so test it directly with a stubbed model argument. Then write exactly one feature test per model asserting round-trip behaviour (fill → save → fresh()), mass-assignment survival, and toArray() output.
Centralise the trait in a base model. Put use Mutable; on App\Models\BaseModel so the day you want to swap the package out (see Gotchas) you edit one file, not forty.
Encryption-at-rest pattern. setValue encrypts, getValue decrypts. Useful for legacy columns you cannot migrate to encrypted casting. Guard against re-encrypting on every save — see the dirty-tracking pitfall below.
Trait precedence will bite you. If a model (or a parent class, or another trait like a soft-deletes/auth override) already defines getAttributeValue or setAttribute, PHP resolves by trait inclusion order and you get silent no-ops rather than errors. If mutators "just don't fire" on one model but work on another, this is why. Use insteadof/as aliasing, or check with:
(new ReflectionMethod(Customer::class, 'getAttributeValue'))->getFileName();
Never mix a mutator and a hand-written accessor on the same attribute. One wins, and which one wins is not obvious. Delete the old getFooAttribute/setFooAttribute in the same commit.
Order of operations with $casts is version-dependent. Test the interaction explicitly for a cast attribute (datetime, array, decimal:2): does your getValue receive the raw string or the already-cast Carbon/array? Getting this wrong produces type errors that only appear on hydration, not on fill().
Dirty tracking and isDirty(). Setters change the value being written, so a setter that normalises will mark the model dirty even when the user submitted an equivalent value ("555-1234" vs "5551234"). For saving hooks, updateOrCreate, or anything gated on isDirty(), compare normalised values yourself before saving. Also getOriginal() returns the raw DB value, not the mutated one — use getRawOriginal() when you want to be explicit.
Query builder, insert(), update() and DB::table() bypass mutators entirely. So do pluck() on some paths and any bulk operation. Mutators protect the Eloquent write path only. If you need guarantees on imports/seeders that use raw SQL, normalise in the importer too.
Debugging triad. When output looks wrong:
$model->getAttributes(); // what's actually on the model
$model->getRawOriginal('phone'); // what came out of the DB
$model->phone; // what your mutator produced
Comparing these three isolates "mutator not firing" from "mutator firing twice" from "value never persisted".
Mutator classes are typically resolved per access, not cached. Keep constructors cheap; put expensive work behind a singleton binding or memoise it. Do not store request-specific state on the mutator — it will leak between attributes and, under Octane or queue workers, between requests.
Bypass Eloquent's mutator-existence cache if you register mutations dynamically. If your $mutations array is populated at runtime from config or a plugin registry rather than being a literal property, verify the trait re-reads it; some Eloquent internals memoise hasGetMutator() results.
Small-package risk — insulate yourself. 45 stars and no downstream dependents means the trait override surface is unlikely to be battle-tested against every Laravel minor version bump. Pin the major version, put use Mutable; in exactly one base model, and put a one-method wrapper (mutatedAttribute()) in front of anything you call by name. A future swap to Laravel's native Attribute casts or a hand-rolled getAttributeValue override should be a one-file change.
Best extension point is the base class, not the vendor code. Subclass Awobaz\Mutator\Mutation into App\Mutators\Mutation and put shared helpers (null handling, normalisation utilities) there. You get the same ergonomics and a clean upgrade path.
How can I help you explore Laravel packages today?