SQL Generated Columns and Views: How to Use Them in Your Laravel App

Feature image: SQL Generated Columns and Views: How to Use Them in Your Laravel App

Over the years of working with Laravel Eloquent models, I've come to appreciate how clean and elegant they can make a codebase. Along the way, I've learned which queries they run under the hood and which native SQL features can make those queries more efficient. Today I want to explore two of those features:

  • Generated columns are quite similar to accessors, though they live in the database instead of in your Eloquent models.
  • Table views are like virtual tables—predefined queries that can even have their own Eloquent model.

Now, I know what you might be thinking: moving logic to the database layer can be effective, but doesn’t that hurt visibility? Today, we'll explore how to use migrations to declare generated columns and table views, so the source of truth remains your codebase.

Let's dive in to understand when these features might be a good fit for your Laravel application.

What Are Generated Columns?

A generated column is a table column whose value is automatically calculated based on an expression using other columns in the same row. For example, you might define a column c as the result of adding columns a and b with an expression like a + b.

There are two types of generated columns:

  • Virtual: Calculated on the fly when queried.
  • Stored: Persisted to disk and updated automatically when the row changes.

Generated columns are supported by most modern SQL engines, including MySQL, MariaDB, and PostgreSQL. In this article, we'll use MySQL for all of the examples.

Let's start with a classic: to get the full_name of your users, you can define a full_name generated column.

CREATE TABLE users (
id INT PRIMARY KEY,
first_name VARCHAR(50),
last_name VARCHAR(50),
full_name VARCHAR(100)
GENERATED ALWAYS AS (CONCAT(first_name, ' ', last_name)) VIRTUAL
);

This defines a virtual generated column. It isn't stored on disk, it's computed each time the table is queried. If you want to avoid recalculating it every time, you can define it as stored instead:

full_name VARCHAR(101)
GENERATED ALWAYS AS (CONCAT(first_name, ' ', last_name)) STORED

Stored columns are ideal when performance is critical and the values don't change often. Virtual columns are great when you want to save storage and can afford the extra computation at read time.

Here's how you can define a generated column in a Laravel migration:

Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('first_name');
$table->string('last_name');
$table->string('full_name')
->storedAs('CONCAT(first_name, ' ', last_name)');
});

Once it's in place, you can treat full_name like any other column:

$users = User::where('full_name', 'like', 'John%')->get();

Generated Columns vs. Eloquent Accessors

Now, I know what you are thinking: “Hold on, we already do this with accessors!” You're right! And I bet you've probably written something like this more than once:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
 
class User extends Model
{
protected function fullName(): Attribute
{
return Attribute::make(
get: fn () => "{$this->first_name} {$this->last_name}"
);
}
}

With this accessor in place, calling $user->full_name on a User model instance returns the computed full name.

The end result may seem equivalent to a generated column, but there are some key differences.

Sorting, Filtering, and Indexing

Accessor values are computed only after the data is retrieved from the database. This means you can't use them to sort or filter your queries, nor can you index a table based on them. Generated columns, however, overcome this limitation because they're integrated directly at the database level.

Suppose we have a full_price accessor in our Product model:

protected function fullPrice(): Attribute
{
return Attribute::make(
get: fn () => $this->price * $this->tax_rate
);
}

You can access $product->full_price, but you can't do something like:

Product::where('full_price', '>', 100)->get(); // ❌ Doesn't work

Now let's say we move that logic into a generated column:

Schema::table('products', function (Blueprint $table) {
$table->decimal('full_price', 10, 2)
->storedAs('price * tax_rate')
->index();
});

Now you can run:

Product::where('full_price', '>', 100)->get(); // ✅ Works perfectly

This gives you the ability to filter, sort, and index based on the generated value; all handled efficiently by the database engine.

Just note: if you're using a virtual column, you can filter and sort on it, but you can't index it. If you need to index the column, you'll want to use a stored column.

Getting Values from Eloquent, the Query Builder, and Raw SQL

Accessors only apply to Eloquent model instances. If you query the database using the query builder or raw SQL, the accessor logic won't run, and the computed field won't be included in the results.

For example, here's an accessor that calculates a user's age from their birth_date:

protected function age(): Attribute
{
return Attribute::make(
get: fn () => $this->birth_date
? Carbon::parse($this->birth_date)->diffInYears(now())
: null
);
}

If you fetch a user using Eloquent, the accessor will return the computed age:

$user = User::find(1);
echo $user->age; // ✅ Works

But if you use the query builder instead:

$user = DB::table('users')->where('id', 1)->first();
echo $user->age; // ❌ Doesn't exist

Because no Eloquent model was involved to trigger the accessor, the age field won't be part of the result.

On the other hand, a generated column is part of the actual database schema. That means it will show up no matter how you query it: whether through Eloquent, the query builder, or raw SQL.

Here's how you might add an age generated column to a users table, based on a birth_date column:

Schema::table('users', function (Blueprint $table) {
$table->unsignedInteger('age')
->virtualAs("TIMESTAMPDIFF(YEAR, birth_date, CURDATE())");
});

Now age becomes just another column in your table, computed automatically by the database, and is available no matter how you fetch your data. As we've seen, it's ready to be used in WHERE clauses and sorting:

User::where('age', '>=', 18)->orderBy('age')->get();

Since this calculation depends on the current date, it makes more sense to define age as a virtual column rather than a stored one. A stored age would only update when birth_date is changed (which, in most cases, will never happen!)

Performance Optimization

Last but not least, let's talk performance. Using generated columns can significantly speed up your queries by reducing the number of records you need to scan, and by allowing you to index those computed values for even faster lookups.

But, on top of that, there's another important advantage: the computation itself is offloaded to the database engine, which is highly optimized for tasks like date comparisons, averages, and other expressions.

For example, imagine you want to know if the subscription is active, meaning its start_date is in the past and end_date is in the future.

If you handled this with an accessor, your is_active method might look like this:

protected function isActive(): Attribute
{
return Attribute::make(
get: fn () => $this->start_date <= now() && $this->end_date >= now()
);
}

You can get all active subscriptions like this:

Subscription::get()->filter(fn($subscription) => $subscription->is_active);

This approach can be fine for small datasets, but when working with thousands (or even millions) of subscriptions, computing this value in PHP for each record becomes resource-intensive. For every subscription, Laravel has to load the row, run the comparison, and keep the result in memory, which can really slow things down.

Replacing the accessor with a generated column removes this bottleneck. The database engine performs the calculation as part of the query itself, using optimized internal functions.

Here's how you could define an is_active virtual generated column in a migration:

Schema::table('subscriptions', function (Blueprint $table) {
$table->boolean('is_active')
->virtualAs('start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE');
});

With this, you can now query active subscriptions directly in the database:

Subscription::where('is_active', true)->get();

Much better!

So... Should We Always Use Generated Columns?

Not necessarily. Like with most things, there is a tradeoff. While generated columns offer the advantages we discussed above, feel free to stick with accessors when:

  • You want the flexibility of writing more complex PHP logic.
  • You need additional data, like environment variables or authenticated user data, to compute a value.
  • Your datasets are small and you don't need to filter, sort, or index by that field.

What Are Views?

Let's switch gears and talk about views. SQL views are like saved SELECT queries. They can join tables, calculate fields, apply conditions... anything you can do in a SELECT, you can do inside a view. To Laravel, views look just like regular tables.

Imagine you run an online store where, each week, products get a discount thanks to an active promotion. Your migrations might look like this:

Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->decimal('price', 10, 2);
// Other product fields...
});
 
Schema::create('promotions', function (Blueprint $table) {
$table->id();
$table->decimal('discount', 3, 2); // e.g., 0.30 for 30%
$table->date('start_date');
$table->date('end_date');
// Other promotion fields...
});

If you query the products table directly, you'll see the original price. To get the current price with the discount applied, you'd need to join the promotions table and calculate the discount:

SELECT
p.id,
p.name,
p.price AS full_price,
p.price * (1 - pr.discount) AS discounted_price
FROM
products p
LEFT JOIN
promotions pr ON CURRENT_DATE BETWEEN pr.start_date AND pr.end_date;

This works, but writing this JOIN every time can get repetitive and tedious. That's where views come in. You can create a price_list view to encapsulate this logic:

CREATE VIEW price_list AS
SELECT
p.id,
p.name,
p.price AS full_price,
p.price * (1 - pr.discount) AS discounted_price
FROM
products p
LEFT JOIN
promotions pr ON CURRENT_DATE BETWEEN pr.start_date AND pr.end_date;

This creates a view, which you can think of as a read-only table. If records change in the products or the promotions tables, the price_list will reflect those changes.

And you'll be able to query it directly from your application code:

$products = DB::table('price_list')->get();

Pretty useful, right? To make this view available in your project, it's a good idea to add a migration that creates the view for you:

<?php
 
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Migrations\Migration;
 
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<SQL
 
CREATE VIEW price_list AS
SELECT
p.id,
p.name,
p.price AS full_price,
p.price * (1 - COALESCE(pr.discount, 0)) AS discounted_price
FROM foo_products p
LEFT JOIN promotions pr ON CURRENT_DATE
BETWEEN pr.start_date AND pr.end_date;
 
SQL
);
}
 
public function down(): void
{
DB::statement('DROP VIEW IF EXISTS price_list;');
}
};

In some cases, it also makes sense to create an Eloquent model for this view. You can define a typical model, and set the view name as $table.

namespace App\Models;
 
use Illuminate\Database\Eloquent\Model;
 
class PriceList extends Model
{
// Specify the view name as the table
protected $table = 'price_list';
}

Now you can query the view using Eloquent:

$products = PriceList::where('discounted_price', '<', 50)->get();

That's cool! Just remember: this is a read-only model. You can't create records through PriceList; you have to do it through Products.

Now, let's put generated columns and views to the test in an real-world-ish scenario.

Real-World Example: Your First Day at the Online Store

You just landed a new job! On your very first day, the team asks you to optimize a critical part of the application: the profit margin screen.

You open the page and, it's slow. Very slow. Over 10,000 products listed, each with a name, cost, price, and profit margin; all sorted from most to least profitable.

“Maybe we can paginate the results,” you suggest. But they shake their heads: “The previous dev tried pagination, but that broke the sorting.”

Weird. “On it,” you say in Slack, and open the controller. You find the snippet that gets the data:

$products = Product::get()->sortByDesc('profit_margin');

“They're fetching all the data from the table and then sorting the collection,” you say to yourself. But... why?

Looking deeper, you discover that the profit margin is calculated using an accessor in the Product model:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute;
 
class Product extends Model
{
protected function profitMargin(): Attribute
{
return Attribute::make(
get: fn () => $this->price > 0
? round((($this->price - $this->cost) / $this->price) * 100, 2)
: 0
);
}
}

“I see, that explains it.” The profit margin isn't stored in the database; it's calculated on the fly. To sort by this value, the application has to load all products into memory, then sort them.

Now the pagination issue makes sense: if you try to paginate this way, the most profitable products might not appear on the first page. Each page would be sorted independently, completely breaking the global order.

You need that profit_margin column directly in the database. Initially, you consider adding it, running a script to update all existing records, and then writing a saved hook in the model to recalculate it whenever price or cost changes.

But, just at the right moment, you recall a Tighten article about generated columns (this very article, how meta!).

“Let's create a stored generated column,” you say, and add the following migration:

<?php
 
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
 
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table): void {
$table->decimal('profit_margin', 5, 2)
->storedAs('CASE WHEN price > 0 THEN ROUND(((price - cost) / price) * 100, 2) ELSE 0 END')
->index();
});
}
 
public function down(): void
{
Schema::table('products', function (Blueprint $table): void {
$table->dropColumn('profit_margin');
});
}
};

Then, back in the controller, you make a quick but impactful tweak:

-$products = Product::get()->sortByDesc('profit_margin');
+$products = Product::orderBy('profit_margin', 'desc')->get();

You run some benchmarks. Before: ~350 ms. After: ~70 ms. That's an 80% speed boost.

These changes helped in three key ways:

  • The profit margin is now calculated in SQL, not PHP.
  • Sorting happens in the database, and the index on profit_margin helps speed it up.
  • Pagination is now fully possible! You quickly implement it using Eloquent's paginate method.

The page now loads faster than ever.

“Great job!” your boss exclaims, before immediately adding, “Perhaps you could also fix the daily sales report. It used to work, but now, it doesn't.”

Alright, a new challenge to cap off this article (and your first day!). Your boss opens the page in question, and... it's not just slow; it's worse. It simply times out!

You inspect the controller and see it's attempting to return a collection of Orders, grouped by date, counting items, and adding up their prices:

$orders = Order::with('items')
->withCount('items')
->withSum('items', 'price')
->get()
->groupBy(fn ($order) => $order->created_at->toDateString());

The goal is to render this data in a table:

| Date | Total Items | Total in USD |
|------------|-------------|--------------|
| 2025-06-08 | 15 | $450.00 |
| 2025-06-09 | 22 | $730.50 |
| 2025-06-10 | 10 | $300.00 |

You quickly realize the problem lies in the grouping.

How does it work currently? It fetches a massive collection of orders, each pre-loaded with all its items. Every single order and item is pulled into memory as an Eloquent model. This is terrible for performance. Imagine 100,000 orders with just 4 items each: that's half a million models loaded in memory, at once. Yeah, a lot.

Then, the orders collection is grouped by date using toDateString, which extracts just the date part (e.g., 2025-06-08) from the created_at timestamp.

Your goal is to group the data directly within the SQL query itself. Using DATE(created_at) should do the trick:

$orders = Order::selectRaw('DATE(created_at) as date')
->with('items')
->withCount('items')
->withSum('items', 'price')
->groupByRaw('DATE(created_at)')
->get();

However, this throws a frustrating error:

SQLSTATE[42000]: Syntax error or access violation: 1055
Expression #2 of SELECT list is not in GROUP BY clause and
contains nonaggregated column 'my_app.orders.id' which is
not functionally dependent on columns in GROUP BY clause;
this is incompatible with sql_mode=only_full_group_by
(Connection: mysql, SQL:
 
select
DATE(created_at) as date,
(
select count(*)
from `order_items`
where `orders`.`id` = `order_items`.`order_id`
) as `items_count`,
(
select sum(`order_items`.`price`)
from `order_items`
where `orders`.`id` = `order_items`.`order_id`
) as `items_sum_price`
from `orders`
group by DATE(created_at))
 
)

What does this mean? MySQL's only_full_group_by mode requires that all selected columns in a GROUP BY statement must either be the column you're grouping by, or an aggregate function (like SUM, COUNT, etc.). In this case, the withCount and withSum clauses generate subqueries in the SELECT statement, which aren't aggregates, thus breaking the rule.

To make it work, you can switch from Eloquent to the query builder. It might not be as elegant, but it's powerful and exactly what you need here: you can use a JOIN instead of subqueries and group by DATE(created_at) as planned.

$orders = Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->selectRaw('DATE(orders.created_at) as date')
->selectRaw('SUM(order_items.price) as revenue')
->selectRaw('COUNT(order_items.id) as items_sold')
->groupByRaw('DATE(orders.created_at)')
->get();

You hit enter, and that works! And much faster than before. To compare the two approaches, you locally seed 10,000 orders, each with 10 items, and run a benchmark. The results are in:

  • Before: 1,665.098ms
  • After: 156.937ms

That's over 10x faster!

Now, if you need to run a similar query in other parts of the application (say, for a monthly report CSV), rewriting all those lines isn't ideal. While you could use a scope in the Order model, perhaps an even better solution is to create a database view:

CREATE VIEW daily_orders_summary AS
SELECT
DATE(orders.created_at) AS date,
SUM(order_items.price) AS revenue,
COUNT(order_items.id) AS items_sold
FROM
`orders`
INNER JOIN `order_items` ON `orders`.`id` = `order_items`.`order_id`
GROUP BY
DATE(orders.created_at)

You test it locally and it works as expected, so you create the migration:

<?php
 
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Migrations\Migration;
 
return new class extends Migration
{
public function up(): void
{
DB::statement('CREATE VIEW daily_orders_summary AS /* ... the full statement */');
}
 
public function down(): void
{
DB::statement('DROP VIEW IF EXISTS daily_orders_summary');
}
};

And then, a simple model associated with it:

namespace App\Models;
 
use Illuminate\Database\Eloquent\Model;
 
class DailyOrdersSummary extends Model
{
protected $table = 'daily_orders_summary';
}

This brings back the beauty of Eloquent, because now you can do:

// Get dates where more than 100 items were sold
DailyOrdersSummary::where('items_sold', '>', 100)->get();
 
// Get dates where revenue is over $9,000
DailyOrdersSummary::where('revenue', '>', 9000)->get();
 
// Top 10 dates by revenue
DailyOrdersSummary::orderBy('revenue')->limit(10)->get();

You've significantly improved vital parts of the system, making them much faster and more reliable. You don't know what tomorrow will bring, but you've had a fantastic first day!

Conclusion

And this is how generated columns and table views can help you when building Laravel applications.

By shifting logic to the database, you get cleaner code, faster queries, and potentially fewer headaches. Use accessors when it makes sense, but don't hesitate to offload some of that work to the database engine when performance demands it. Whenever you see a complex, large query repeated across the codebase, ask yourself if a table view could be a good fit.

Always create generated columns and views using migrations so that other team members are aware of them.

Did you know about these features? Did you learn something new? Let us know.

Until next time!

Get our latest insights in your inbox:

By submitting this form, you acknowledge our Privacy Notice.

Hey, let’s talk.

By submitting this form, you acknowledge our Privacy Notice.

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.

Thank you!

We appreciate your interest. We will get right back to you.