staudenmeir/laravel-migration-views
Laravel Migration Views adds createView and dropView macros to Laravel's schema Blueprint, letting you create and drop database views directly in migrations alongside your tables. Supports MySQL, PostgreSQL, SQLite and SQL Server.
Install and prove it out in one migration.
composer require staudenmeir/laravel-migration-views
No config to publish, no service provider to register — it's auto-discovered and only adds macros to Laravel's Schema builder. The whole API surface you'll use day-to-day is five calls:
Schema::createView('active_users', 'SELECT id, email FROM users WHERE active = 1');
Schema::createOrReplaceView('active_users', 'SELECT id, email, name FROM users WHERE active = 1');
Schema::dropView('active_users');
Schema::dropViewIfExists('active_users');
Schema::renameView('active_users', 'enabled_users');
First use case to try: a reporting read model that more than one consumer already depends on — a dashboard query, an export job, or an Eloquent model that duplicates a three-table join. Migrate that view definition out of a wiki page or a BI tool config and into a normal migration file. That's the smallest change that demonstrates the value: the view now ships with php artisan migrate, gets reviewed in a PR, and rolls back.
Where to look first: the README for the exact macro signatures, and your database/migrations directory conventions — everything else is Laravel you already know. There is no new abstraction to learn.
// database/migrations/2026_01_15_000000_create_order_summary_view.php
public function up(): void
{
Schema::createView('order_summary', '
SELECT
o.customer_id,
COUNT(*) AS order_count,
SUM(o.total) AS lifetime_value
FROM orders o
WHERE o.status = "completed"
GROUP BY o.customer_id
');
}
public function down(): void
{
Schema::dropView('order_summary');
}
One migration per view, ordered after its dependencies. The migration timeline is your dependency graph. A view that selects from orders must land in a file with a later timestamp than the migration that creates orders. When you have three views where two read from the third, interleave them deliberately — don't batch all views into a single timestamp.
Changing a view = a new migration, never an edit. Applied migrations are immutable history. To modify a live view, write a new migration using createOrReplaceView. Keep the old migration untouched so a fresh migrate replays the same sequence production went through.
// 2026_03_02_000000_add_name_to_order_summary_view.php
public function up(): void
{
Schema::dropViewIfExists('order_summary');
Schema::createView('order_summary', '...SELECT with the new column list...');
}
public function down(): void
{
Schema::dropViewIfExists('order_summary');
Schema::createView('order_summary', '...the previous definition verbatim...');
}
Read it back through Eloquent. Views are relations. Point a read-only model at one and you get scopes, accessors, and eager loading for free:
class OrderSummary extends Model
{
protected $table = 'order_summary';
public $timestamps = false;
protected $guarded = [];
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class, 'customer_id');
}
}
Treat these models as read-only in code review. Writes to a view will fail at runtime or, worse, silently succeed against MySQL updatable views — which is a bug waiting for a Friday deploy.
Compatibility shims during a refactor. The highest-leverage pattern: before splitting users.name into first_name/last_name, create a view exposing the old shape. Legacy code keeps working while you migrate callers incrementally. Drop the view in a later migration once nothing references it.
Keep the raw SQL in a dedicated location. Long view bodies inline in the migration file age badly. Put the SQL in database/views/order_summary.sql (or a small static class) and reference it — you get editor syntax highlighting and diffable changes.
Test the rollback, not just the migrate. Every view migration should be exercised by a migrate → migrate:rollback → migrate cycle in CI. Orphaned views don't break this deploy; they break the next one, when a table of the same name can't be created.
Verify schema dump parity. If you use php artisan schema:dump (worth doing on large projects), confirm the resulting dump includes your views and that schema:load restores them on a scratch database. This is the one place the package's guarantees stop and the database's begin.
Never use SELECT * in a view body. Enumerate every column. With *, adding a column to the base table silently changes the view's shape — and on PostgreSQL, the view is frozen at the column list it had when created, so your local and production schemas quietly diverge. Enumerating columns makes base-table changes fail loudly at migration time.
PostgreSQL: CREATE OR REPLACE VIEW cannot change the output column list. You can change the WHERE clause, add computed columns at the end — but not reorder, rename, or remove. When the column list must change, it's drop-and-recreate, which drops the grants with it. Re-grant SELECT to your reporting role in the same migration. This is why the earlier example uses dropViewIfExists + createView rather than createOrReplaceView: it behaves identically on every engine and never lies about what it changed.
Treat view column names as a public contract. They're consumed by Eloquent models, BI tools, and exports. Renaming is a breaking change and needs the same deprecation discipline as renaming a table column.
Drop order on rollback. Views must be dropped before the tables they read from. Laravel rolls back in reverse timestamp order, which handles this automatically if you follow one-migration-per-view and keep dependencies ordered. Bundle a view and its table into one migration and you will hit a dependency error on rollback.
Dialect divergence between CI and production is the real risk. Plain views work on MySQL, PostgreSQL, SQLite, and SQL Server — but the SQL inside them doesn't. SQLite in an in-memory test suite lacks window functions on older versions, doesn't support FULL OUTER JOIN, and treats type affinity differently. If you test on SQLite and deploy on PostgreSQL, your view definitions are untested where it counts. Either run your CI database tests against the production engine, or guard engine-specific views behind a driver check:
if (DB::connection()->getDriverName() === 'pgsql') {
Schema::createView('...', '...');
}
MySQL view definers. MySQL stores the creating user as the view DEFINER. Dumps restored into an environment where that user doesn't exist will fail or behave oddly. If your deploy user varies across environments, be aware of this in dump/restore procedures.
Materialized views are not in the documented core. The package's stated scope is standard views across four engines. PostgreSQL materialized views (and their refresh lifecycle — REFRESH MATERIALIZED VIEW CONCURRENTLY, unique indexes, scheduling, the fact that CI's SQLite suite can't see them at all) are a separate design decision with a separate owner. Settle that question before you scope any dashboarding work; don't assume coverage.
No config, so nothing to debug in configuration. When something fails, the failure is in your SQL or in the database's DDL rules — not in a misconfigured publish step. Read the actual error string; it's coming from the engine.
Exit cost is genuinely near zero. Every call here is a one-line wrapper over plain DDL. If you ever need to remove the package, replace Schema::createView('x', $sql) with DB::statement('CREATE VIEW x AS ' . $sql) and you're done. That low exit cost is worth stating explicitly when someone asks about the single-maintainer dependency.
Assign an owner to every view at creation time. The package removes the tooling excuse for stale reporting SQL, not the organizational one. Without ownership, you rebuild the same tribal-knowledge problem with cleaner commit history.
How can I help you explore Laravel packages today?