I recently ran a performance audit on Growth Method and found something I wasn’t expecting: a single database column was slowing down every page in the app.
The invisible column
When I added semantic search, I stored a vector(1536) column called embedding on the campaigns table. This column holds 1,536 numbers that represent the meaning of each campaign’s text — it’s what powers the ⌘K search box.
The problem: every time the app loads a list of campaigns — the main table, reports, charts, basically every page — it asks the database for all columns using SELECT *. That includes the embedding. Each row’s embedding is roughly 6KB of data that the app never displays and never uses, except inside the search box.
A page showing 20 campaigns was loading ~120KB of vector data it never uses.
Why it matters more with Livewire
It gets worse. Growth Method uses Livewire, which stores component data as JSON in the HTML. When a Livewire component holds a campaign model as a public property, that model — including the 6KB embedding — gets serialised into the page on every round-trip.
Every button click, every form input, every autosave: 6KB of vector data bouncing between the browser and the server. For the campaign detail page, this happens dozens of times per session.
What does $hidden actually do?
The campaign model already had embedding in its $hidden array:
protected $hidden = [
'embedding',
];
I assumed this prevented the column from being loaded. It doesn’t. The $hidden property only affects JSON serialisation — it hides the column from API responses and toArray() calls. The database still sends the full 6KB on every query. The Eloquent Serialization docs confirm this.
| Property | What it does | Prevents DB load? |
|---|---|---|
$hidden | Excludes from toArray() / toJson() | No |
select() | Specifies which columns to fetch | Yes, but fragile |
| Global scope | Automatically modifies every query | Yes |
Does Laravel have a built-in solution?
I searched the Laravel 13 docs and the laravel/framework GitHub repo. There is no native “exclude column” or “deferred attributes” feature in Laravel. The community has requested it multiple times:
- Issue #4408 (2014) — proposed an
exclude()method on the query builder. Never implemented. - Discussion #51402 (2024) — community request for
Model::except(['column']). No official response.
Taylor Otwell has never publicly commented on or championed a column-exclusion feature. The framework’s position: the existing tools are sufficient.
The questions I asked
Three questions shaped the decision:
Me: Is there anything else that could be causing performance issues or slowing down the app in general?
The audit found the embedding column was the single biggest issue — larger than any N+1 query or missing index. Every page was affected.
Me: Explain this in more detail. What are best practices, and what would Taylor Otwell do?
The research came back with three options, community perspectives, and a clear recommendation. The key insight: Livewire re-hydrates models using Model::find($id) on every request, so any solution needs to work automatically — you can’t rely on remembering to add select() to every query.
Me: How does Caleb Porzio’s suggestion about computed properties relate to the global scope option?
This helped me understand that global scopes and computed properties solve different layers of the same problem. The global scope fixes the database layer (don’t fetch the column). Computed properties fix the Livewire layer (don’t serialise the model). Both are complementary, but the global scope is the quick win.
Three options I evaluated
Option 1: Global scope (what I chose)
Add a global scope to the model that replaces SELECT * with an explicit column list minus embedding. When you need the embedding (search), bypass it.
Option 2: Separate table
Move embedding to its own campaign_embeddings table. Load it only when needed via a relationship. This is the textbook normalisation approach.
Option 3: Explicit select() on every query
Add ->select([...]) to every place campaigns are queried, listing all columns except embedding.
| Global scope | Separate table | Explicit select() | |
|---|---|---|---|
| Automatic | Yes — every query, everywhere | Yes — column isn’t on the table | No — must remember every time |
| Works with Livewire hydration | Yes — scope applies on find() | Yes | No — Livewire warns against this |
| Migration needed | No | Yes | No |
| Fragile | No | No | Yes — easy to miss a query |
| Effort | 10 lines | Migration + model + job updates | Every query in the app |
The Livewire row is the dealbreaker. The Livewire docs explicitly warn that “query constraints like select(...) will not be re-applied on subsequent requests”. When Livewire re-hydrates a model between requests, it calls Model::find($id) without your select() — so the embedding loads anyway on every round-trip.
The solution: a ten-line global scope
protected static function boot(): void
{
parent::boot();
static::addGlobalScope('exclude-embedding', function (Builder $builder) {
if (empty($builder->getQuery()->columns)) {
$columns = once(fn () => collect(Schema::getColumns('campaigns'))
->pluck('name')
->reject(fn ($col) => $col === 'embedding')
->all());
$builder->select($columns);
}
});
}
Two parts worth noting:
if (empty(...))— only fires onSELECT *queries. If a query already has explicit columns, the scope steps aside.once()— caches the column list in memory so the schema lookup runs once per request, not once per query.
When I do need the embedding (the Pulse dashboard counts how many campaigns have embeddings), I bypass the scope:
$counts = Campaign::withoutGlobalScope('exclude-embedding')
->toBase()
->selectRaw('count(*) as total, count(embedding) as with_embedding')
->first();
This follows the same pattern Laravel uses for soft deletes — apply a sensible default globally, provide an escape hatch (withoutTrashed() / withoutGlobalScope()) for the rare case where you need the full data.
Verifying it works
A quick check in Tinker confirms the embedding is excluded:
-- Before (SELECT *)
select * from "campaigns" where "campaigns"."deleted_at" is null limit 1
-- After (explicit columns, no embedding)
select "id", "team_id", "name", "description", ... from "campaigns"
where "campaigns"."deleted_at" is null limit 1
Queries with explicit select() are unaffected — the scope’s guard ensures it only intervenes on SELECT *.
What would Taylor do?
A global scope is how Laravel’s own SoftDeletes works — applied automatically, bypassable when needed. If it’s good enough for soft deletes, it’s good enough for this.
Also found: a PostgreSQL index gotcha
While auditing, I also discovered that three tables had unindexed foreign key columns. PostgreSQL doesn’t auto-create indexes on foreign keys (MySQL does). The comments, metric_results, and shared_links tables all had an idea_id foreign key that was being queried on every campaign page load — with no index.
I confirmed with EXPLAIN ANALYZE that the comments table was doing a full sequential scan:
Seq Scan on comments (cost=0.00..20.12 rows=4 width=72)
Filter: (idea_id = 1)
Rows Removed by Filter: 46
A single migration fixed all three:
public function up(): void
{
Schema::table('comments', fn (Blueprint $table) => $table->index('idea_id'));
Schema::table('metric_results', fn (Blueprint $table) => $table->index('idea_id'));
Schema::table('shared_links', fn (Blueprint $table) => $table->index('idea_id'));
}
If you migrated from MySQL to PostgreSQL (like I did), check your foreign keys. The indexes you assumed existed might not be there.