Eloquent accessors and mutators: lessons from production
You've probably faced the scenario where a column needs to be formatted differently depending on context, or user input needs normalization before hitting the database. Eloquent accessors and mutators are Laravel's answer to this challenge, but we've learned some hard lessons about when and how to use them effectively. In this article, we'll walk through the modern approach with Attribute::make, show you real patterns from e-commerce and SaaS applications, and highlight the pitfalls we've encountered along the way.
Understanding Attribute::make with get and set
Before Laravel 8.77, accessors and mutators were defined as separate methods with specific naming conventions. The newer Attribute::make approach is cleaner, more explicit, and easier to test. Here's the foundational pattern:
use Illuminate\Database\Eloquent\Casts\Attribute;
class Product extends Model
{
protected function price(): Attribute
{
return Attribute::make(
get: fn ($value) => $value / 100, // Convert cents to dollars
set: fn ($value) => $value * 100, // Convert dollars to cents
);
}
}
The get callback fires whenever you access the attribute—either through direct property access like $product->price or via getAttribute(). The set callback fires when you assign a value, before it's stored in the model's internal state. The key thing to understand is that accessors are read transformations, mutators are write transformations, and both should be lightweight operations.
You can define only a getter if you don't need to transform on write, or only a setter if the read path doesn't need special handling. This flexibility is what makes Attribute::make superior to the older approach.
Format money and names on read
Money formatting is one of the most common use cases we see in production. Whether you're building an e-commerce platform or a financial SaaS, you'll store currency as an integer (cents) in the database to avoid floating-point precision issues, then format it for display.
class Order extends Model
{
protected function totalAmount(): Attribute
{
return Attribute::make(
get: fn ($value) => number_format($value / 100, 2, '.', ''),
);
}
}
// Usage
$order = Order::find(1);
echo $order->total_amount; // "1234.56" as a string
A practical example from a travel booking platform: when displaying a reservation, you want the price formatted to two decimal places with currency symbols. Rather than formatting in the controller or view for every endpoint, the accessor ensures consistency across your API and web layers.
For names, we often see patterns like title-casing or ensuring consistent capitalization:
class User extends Model
{
protected function firstName(): Attribute
{
return Attribute::make(
get: fn ($value) => ucfirst(strtolower($value)),
);
}
}
However, be cautious here: if a name accessor is called thousands of times in a loop (like formatting a CSV export of 50,000 users), the repeated transformation can become a bottleneck. We'll discuss this in the "Keep heavy logic out of accessors" section.
Normalize emails on write
Email normalization is a textbook mutator use case. By normalizing on write, you guarantee that every email stored in your database is lowercase and trimmed, eliminating data inconsistency bugs down the road.
class User extends Model
{
protected function email(): Attribute
{
return Attribute::make(
set: fn ($value) => strtolower(trim($value)),
);
}
}
// Usage
$user = new User();
$user->email = ' JOHN@EXAMPLE.COM ';
$user->save();
// Database now contains: john@example.com
In a ride-hailing application, we used a similar pattern to normalize phone numbers, removing dashes and spaces so the database always held a consistent format. This made searching and deduplication far simpler.
The beauty of mutators is that they apply regardless of how the data enters the model—whether it comes from user input, an API request, or a bulk migration. You're not relying on validation rules or controller logic to enforce format consistency.
One caveat: if you need to know the original value before mutation, you'll need to check it before assignment or use getOriginal('email') after setting.
Keep heavy logic out of accessors
This is where production experience becomes crucial. Accessors are convenient, but they can hide performance problems. Every time you access an attribute with an accessor, that callback runs. If that callback makes a database query or performs expensive calculations, you're potentially multiplying that cost across your application.
Here's an antipattern we've seen in real codebases:
// BAD: This queries the database on every access
protected function averageRating(): Attribute
{
return Attribute::make(
get: fn () => $this->reviews()->avg('rating'),
);
}
// Then in a controller:
$products = Product::all();
foreach ($products as $product) {
echo $product->average_rating; // N+1 queries!
}
The solution is to eager-load related data and keep the accessor lightweight:
// BETTER: Accessor uses pre-loaded data
protected function averageRating(): Attribute
{
return Attribute::make(
get: fn () => $this->reviews_avg_rating ?? 0,
);
}
// In your query:
$products = Product::withAvg('reviews', 'rating')->get();
// Now $products->average_rating uses the pre-computed value
Another pattern: if you need complex transformations (parsing JSON, formatting for external APIs, running calculations), consider moving that logic to a dedicated method or service class, then call it from the accessor only if truly needed. Keep accessors for simple, pure transformations.
From a CRM platform we worked on, we had an accessor that joined three related tables to build a "full customer profile" string. It was causing serious performance degradation. Moving that to an explicit method and calling it only in specific contexts cut response times by 40%.
Common mistakes
- Calling accessors in loops without eager loading: An accessor that references a relationship without eager loading creates an N+1 query problem. Always use
with()orload()when you know you'll access those attributes in bulk. - Mutating input without validation: A mutator should normalize or transform, but validation belongs in form requests or model rules. Don't silently accept invalid data in a mutator and rely on the mutated value being "safe."
- Storing derived data instead of computing it: If an attribute is purely derived (like full name from first and last), don't save it to the database. Compute it on read via an accessor. This keeps your source of truth clear and prevents sync bugs.
- Assuming accessor return type consistency: If an accessor sometimes returns a string and sometimes an integer, downstream code will break. Be explicit about the type you return, even if it means extra casting.
- Forgetting that accessors apply during serialization: When you serialize a model to JSON (via
toArray()ortoJson()), accessors are applied. If you're exposing an API, document this so consumers know they're receiving transformed values, not raw database values.
Key takeaways
- Use
Attribute::makewithgetandsetcallbacks for clean, modern accessor and mutator definitions. - Accessors are ideal for formatting output (currency, dates, names); mutators are ideal for normalizing input (emails, phone numbers, slugs).
- Keep accessor and mutator logic lightweight—avoid database queries, complex loops, or expensive calculations inside them.
- Eager-load relationships before accessing computed attributes in bulk to prevent N+1 query problems.
- Remember that accessors apply during serialization, so they affect what your API returns; be intentional about this behavior.
- Validate input separately from mutation; a mutator should transform, not enforce business rules.






Comments
Loading comments…