Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 089 / 100

Eloquent accessors and mutators: lessons from production

Transform attributes on read and write.

Expona Admin6 min read

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:

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

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

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

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

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

php
// 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() or load() 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() or toJson()), 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::make with get and set callbacks 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles