korridor/laravel-computed-attributes
A Laravel package for adding computed attributes to Eloquent models. Define derived values from existing model attributes and expose them cleanly alongside database columns, helping keep model logic organized, reusable, and easy to access.
The package is a trait plus two artisan commands. There is no service to run, no queue, no external dependency — a first attribute is roughly a 20-minute task, and a production rollout on one model is a day.
1. Install, pinned to the right major. v3.x targets Laravel 10/11/12/13 (3.3.0 added Laravel 13). If you are still on Laravel 9, you need the 2.2.x line. Confirm the support matrix on Packagist rather than trusting changelogs, and pin with "korridor/laravel-computed-attributes": "^3.3" — this package's release cadence is one-to-one with Laravel majors, so it gates your upgrade calendar.
2. Add the column. It is a real column, so write a normal migration. Match the column type to what the compute method returns before you write the method:
Schema::table('orders', function (Blueprint $table) {
$table->bigInteger('total_cents')->nullable()->after('status');
$table->index('total_cents'); // the whole reason you're here
});
3. Declare the attribute on the model. The mapping is column name => method name on the $computedAttributes property; the trait recalculates and writes those columns on every save().
use Korridor\LaravelComputedAttributes\ComputedAttributes;
class Order extends Model
{
use ComputedAttributes;
protected $fillable = ['customer_id', 'status'];
/** @var array<string, string> */
protected $computedAttributes = [
'total_cents' => 'computeTotalCents',
];
protected $casts = ['total_cents' => 'integer'];
public function computeTotalCents(): int
{
return (int) $this->items->sum(
fn ($item) => $item->unit_price_cents * $item->quantity
);
}
}
4. Register the model in the published config (config/computed-attributes.php, models array). Both artisan commands iterate this list; a model that is missing from it silently never gets backfilled or validated. Publish with php artisan vendor:publish --tag=computed-attributes (check the README for the exact tag on your version).
5. Backfill, then verify.
php artisan computed-attributes:generate --attributes=total_cents
php artisan computed-attributes:validate --attributes=total_cents
First use case to pick: a denormalized search or sort key — users.full_name for LIKE search, or orders.total_cents for sorting. That is where the value is unambiguously worth a column. Do not start with a value that only appears in one Blade template; a plain accessor plus $appends is free and carries no consistency burden.
Where to look first in the source: the trait's save() path (to know exactly when recomputation fires) and the two command classes (to know the exact option names, chunking behavior, and exit codes on your pinned version). Secondary write-ups of this package do not reliably match the code.
The four-step change is the unit of work. Column migration → $computedAttributes entry → computeX() method → config registration. Treat that as a checklist and put it in the PR template; the most common failure is a model that recomputes on save but was never added to the config, so generate/validate never touch it.
Keep computations pure and row-local. The formula should read the model's own columns or an already-loaded relation. No HTTP calls, no now()-dependent logic that changes between the write and the validate run, no dependence on the current user or locale — otherwise validate becomes a permanent false-alarm generator.
Define the command scopes for eager loading. Both commands honor scopes so backfills don't N+1 across millions of rows:
public function scopeComputedAttributesGenerate($query)
{
return $query->with('items');
}
public function scopeComputedAttributesValidate($query)
{
return $query->with('items');
}
This is the credible answer to "how do we backfill 2M rows without melting the DB," and it is the thing to cite when sizing the work.
Operationalize drift detection — this is the real day-to-day value. Schedule computed-attributes:validate (nightly or weekly) and run it in CI against a seeded database. Because generate skips the write when isDirty() is false (since 2.2.0), you can also schedule generate cheaply as a self-healing sweep. Add an alert on non-zero validate output; that turns "denormalized data silently drifts" into a monitorable metric instead of a theoretical objection.
Respect write-order for aggregates. The value is computed from in-memory state at save time. If an order's total_cents depends on items, saving an item does not update the order. Either recompute on the parent after children change ($order->save() / $order->touch() in the item's saved observer), or accept staleness and let the nightly generate sweep close the gap. Decide explicitly, per attribute, which one you have.
Query it like a column, because it is one. The payoff:
Order::whereBetween('total_cents', [$min, $max])
->orderByDesc('total_cents')
->paginate();
Index the column in the same migration that adds it, in the same PR that adds the attribute — not in a later "performance" ticket.
Test it in two layers. Unit-test computeTotalCents() directly with an in-memory model; feature-test that $order->save() produces the expected column value, and that the JSON response contains it (real columns serialize automatically). Add one test that validate passes on a freshly generated factory fixture — that catches cast/rounding mismatches before production does.
Denormalize deliberately, and document it. Add a comment in the migration (// Derived. Authoritative source: order_items. Recompute via computed-attributes:generate.) and note it in the model docblock. Once BI or an API client filters on the column, it is part of the persisted schema contract, and the next developer needs to know it is not authoritative.
query()->update(), Model::insert(), upsert(), increment()/decrement(), raw SQL, ETL imports, database triggers, other services. The value drifts silently. Audit your codebase for these on any table that gains a computed attribute, and route them through Eloquent (or schedule generate).validate uninterpretable. Compute both from the underlying source columns.isDirty() compares against the cast value; 0.1 + 0.2 recomputed as 0.30000000000000004 will look dirty every run and rewrite the row forever. Store integers (cents, basis points) or cast to decimal:2 and round explicitly inside the compute method.$fillable. A mass assignment from a request payload should never overwrite a derived value. Guard them, and remember that once they are columns they serialize into toArray()/JSON automatically — you do not need $appends, and adding it is harmless but misleading.save() still fires model events. The generate command saves models, so observers run. The isDirty() guard means unchanged rows don't write and updated_at doesn't move, but a heavy updated/saved observer will still execute per row during a backfill. Keep observers cheap or scope the backfill down.computeX() touches $this->items and the relation isn't loaded, every row during generate fires a query. The scope fixes it; forgetting the scope on validate is the more common miss because it runs less often and slower.validate before you start filtering on the column in application code.How can I help you explore Laravel packages today?