splitstack / laravel-rome
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.29
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-drift: ^3.0
- phpstan/phpstan: ^2.0
This package is auto-updated.
Last update: 2026-06-21 10:33:12 UTC
README
Laravel Rome
Make database views first-class citizens in your Laravel app.
Laravel Rome gives you three complementary tools:
HasReadOnlyModetrait — add to any existing Eloquent model to get areadonly()guard on instances and afromView()fluent builder that queries directly from a dedicated DB view, wrapping every result in a write-protected proxy.ReadOnlyModel— a purpose-built Eloquent base class for models that live entirely on a view. Blocks direct writes, and optionally proxies mutations through a separate writable model. Works fantastically with Livewire components and anywhere else you want to hold view state in memory and write back through it without juggling two separate models.- Tooling — scaffold views with
make:dbview, regenerate them across connections withdbview:regen(multi-tenant aware), refresh materialized views via a queued job, and catch misuse at build time with bundled PHPStan rules.
Works with PostgreSQL and MySQL, with optional multi-tenant support.
Requirements
- PHP 8.2+
- Laravel 11+
- Database: PostgreSQL 9.3+ or MySQL 5.7+
Installation
composer require splitstack/laravel-rome
Publishing
Publish the Laravel config:
php artisan vendor:publish --tag=rome-config
Publish the PHPStan extension (optional — see PHPStan rules):
php artisan vendor:publish --tag=rome-phpstan
This copies extension.neon to phpstan-rome.neon at your project root, where you can customise it (e.g. set a non-standard db_views_path). Then include it in your phpstan.neon:
# phpstan.neon includes: - phpstan-rome.neon
If you don't need to customise anything, you can skip publishing and include the extension directly from the vendor path:
# phpstan.neon includes: - vendor/splitstack/laravel-rome/extension.neon
Configuration
// config/rome.php return [ // Path where your .sql view files live 'db_views_path' => database_path('views'), // Connections used for view operations. Must be configured. // Views are run against each connection in order. 'db_connections' => ['pgsql'], // e.g. ['pgsql'] or ['analytics', 'reporting'] // --- Multi-tenancy (optional) --- 'tenant_model' => null, // e.g. App\Models\Tenant::class 'tenant_status_column' => 'status', 'tenant_active_status' => 'active', // Directories scanned when make:dbview offers the model picklist. // App\Models is always included. Paths are relative to app_path(). 'model_scan_paths' => [], // e.g. ['Domain/Orders/Models'] // Where make:dbview places generated read-only view models. // Path is relative to app_path(); namespace is derived automatically. 'readonly_model_path' => 'Models/Views', ];
ReadOnlyModel
ReadOnlyModel is the Eloquent model you point at a database view. Reading from it works exactly like any other model. Writing directly with save() or delete() is intentionally blocked.
However, we provide a fluent way to proxy the underlying writable model for updates, and to access the underlying model instance for event dispatch or method calls.
In most well-architected apps you won't need the write-proxy at all. If you already know the record's ID — which is typical in a standard controller — reach for the writable model directly:
Product::find($id)->update(...). The proxy system pays off in situations where you're already holding aReadOnlyModelinstance and want to get a writable model from it without an extra query: a Livewire component whose state is the view model, a shared action/service class that expects a writable Eloquent model, or any place where splitting your state across two models would be awkward. If neither of those applies, skip$proxyToentirely.
Enabling proxy operations
Proxy operations (update, underlying, proxied) are off by default.
To use them, you should:
- Turn on the global switch — set
rome.proxy_enabled => trueinconfig/rome.phpor, if you don't need to publish, set the environment variableROME_PROXY_ENABLED=true. - Define
protected static $proxyTo— the writable Eloquent model to proxy to.
Calls to proxy operations throw a ProxiedModelException if either of these conditions is not met.
Create your first read-only view model
use Splitstack\Rome\Models\ReadOnlyModel; class OrderSummaryView extends ReadOnlyModel { protected $table = 'order_summary_view'; protected $primaryKey = 'id'; // defaults to 'id' if omitted; override if your view's primary key is different protected static $proxyTo = Order::class; protected static array $exclude = ['total_price']; }
| Property | Type | Purpose |
|---|---|---|
$table |
string |
The view name in the database |
$proxyTo |
class-string|null |
Writable model that owns the underlying table. Required to enable proxy operations |
$exclude |
string[] |
Columns stripped when hydrating via proxied() / underlying(false). See computed column warning |
$primaryKey |
string (default: id) |
The primary key column name. Override if your view's primary key is different. |
Primary Key configuration
ReadOnlyModel declares a non-incrementing primary key named id but makes no assumption about key type. Set $keyType, $incrementing, and any $casts on your model to match your actual key type. The model set in $proxyTo must use the same primary key name and type, since all proxy lookups use $this->getKey() to locate the record in the proxied table.
Make sure to override protected $primaryKey if your view's "primary key" is not id.
⚠ Warning - if your view does not have a unique column or set of columns that can serve as a primary key, you will not be able to use proxy operations. You can still use the model for querying and read operations, but updates through the proxy are not possible without a reliable way to identify the underlying record.
Proxy operations
update(array $attributes)
Looks up the matching record in the proxied model by primary key, updates it, then re-fetches and returns the view record so your computed columns are up to date.
$summary = OrderSummaryView::find($id); $summary->update(['status' => 'shipped']); // returns OrderSummaryView
Throws if no matching record exists in the proxied table.
save() and delete()
They always throw regardless of proxy configuration. This is a safety measure to prevent accidental overwrites through the view model. Use update() for updates, and call underlying()->delete() for deletions.
Accessing the underlying model
underlying(bool $forceFetch = true)
Returns a proxied model instance. The default is forceFetch: true — it queries the proxied model's table directly so all attributes are present and reflect the real stored values.
$order = $summary->underlying(); // hits the database; all attributes present
Pass forceFetch: false to hydrate in-memory from the view's attributes intersected with the proxied model's $fillable. No database query is made, but attributes not in $fillable are absent, and computed column values are taken from the view — see the warning below.
$order = $summary->underlying(forceFetch: false); // no query; $fillable attributes only, faster but riskier
proxied()
Alias for underlying(forceFetch: false). Intended for cases where you need a writable model instance for event dispatch, method calls, or other non-persistence uses and can accept the in-memory hydration trade-offs.
$order = $summary->proxied(); // no query; $fillable attributes hydrated from the view
Danger: computed columns that share a name with the underlying table column
If your view computes a value under the same column name that exists in the proxied model's table,
proxied()andunderlying(forceFetch: false)will silently hydrate the proxied instance with the computed value from the view, not the raw stored value. Callingsave()orupdate()on that instance can then write the computed value back to the table, corrupting data.Example: a view computes
total_priceasquantity * unit_price. Theorderstable also has a storedtotal_pricecolumn. Callingproxied()populates$order->total_pricewith the view-computed figure. If that instance is then updated, the computed figure overwrites the stored one.We provide a
php artisan rome:checkcommand that scans your view SQL for computed columns that share names with the proxied model's table columns, and reports any dangerous collisions it finds. Be aware that this command is not perfect — it looks for simple patterns in the SQL and may miss complex cases or produce false positives. Always review the view SQL and your$excludelist carefully to ensure all computed columns are accounted for.The safest fix is to rename computed columns in the view SQL so they cannot collide:
SELECT quantity * unit_price AS computed_total_price, -- unambiguous alias item_count AS computed_item_count FROM ordersWhen renaming is not possible (e.g. the view is shared or generated), use
$excludeto strip the dangerous attributes before hydration:class OrderSummaryView extends ReadOnlyModel { protected static $proxyTo = Order::class; // Stripped when hydrating via proxied() / underlying(false) protected static array $exclude = ['total_price', 'item_count']; }
$excludehas no effect onunderlying(forceFetch: true), which always reads from the database. UseforceFetch: true(the default) whenever you intend to write back through the proxied model. Only useproxied()orunderlying(false)when you explicitly do not need the stored values and have audited both your column aliases and your$excludelist.
HasReadOnlyMode
HasReadOnlyMode is a trait you can add to any Eloquent model — writable or not — to expose a read-only interface to its table or a dedicated read view. It is a lighter alternative to ReadOnlyModel for situations where you want to keep a single model class but need a guarded, view-backed query path alongside it.
use Illuminate\Database\Eloquent\Model; use Splitstack\Rome\Concerns\HasReadOnlyMode; class Product extends Model { use HasReadOnlyMode; // Optional: point fromView() at a separate DB view instead of the model's own table. // PHP 8.2+ forbids re-declaring the trait property with a different value, // so set this via booted() rather than a property declaration. protected static function booted(): void { static::$readOnlyView = 'products_summary_view'; } }
readonly()
Called on a model instance, returns a ReadOnlyProxy that passes attribute reads and non-mutating method calls through to the wrapped model but throws ReadOnlyModelException on save(), delete(), or update().
$product = Product::find($id); $proxy = $product->readonly(); $proxy->name; // ✓ read $proxy->toArray(); // ✓ serialisation $proxy->save(); // ✗ throws ReadOnlyModelException
fromView()
Static method. Returns a ReadOnlyBuilder — a custom Eloquent builder that:
- queries from
$readOnlyViewwhen set, otherwise from the model's own table - wraps every result (
get,first,sole,find) in aReadOnlyProxy - throws
ReadOnlyModelExceptionimmediately onupdate(),delete(),create(), orfirstOrCreate()
// Query the view; results are ReadOnlyProxy instances $products = Product::fromView()->where('status', 'active')->get(); $product = Product::fromView()->find($id); $product->name; // ✓ $product->save(); // ✗ throws ReadOnlyModelException // findOrFail() throws ModelNotFoundException when the record is missing $product = Product::fromView()->findOrFail($id); // Bulk writes are blocked at the builder level, before any SQL is sent Product::fromView()->update(['status' => 'archived']); // ✗ throws ReadOnlyModelException
ReadOnlyProxy
ReadOnlyProxy is the wrapper returned by readonly() and by all ReadOnlyBuilder query methods. It is not specific to HasReadOnlyMode — you can also construct it directly when you need to prevent accidental mutation of any model instance:
use Splitstack\Rome\Models\ReadOnlyProxy; $proxy = new ReadOnlyProxy($anyModel); $proxy->toArray(); // ✓ $proxy->toJson(); // ✓ $proxy->save(); // ✗ throws ReadOnlyModelException
Nesting a ReadOnlyProxy inside another ReadOnlyProxy is safe — the constructor always unwraps to the underlying Model.
Scaffolding a view
php artisan make:dbview order_summary
The command prompts for the view name if omitted, then offers an interactive picklist of Eloquent models in your app/Models directory (and any paths listed in rome.model_scan_paths). Selecting a model seeds the SELECT column list and the view model's $fillable from that model's $fillable. Choose (none) to start with a blank template.
You can bypass the prompt in scripts:
php artisan make:dbview order_summary --model="App\Models\Order"
This creates three files:
| File | Purpose |
|---|---|
database/views/order_summary.sql |
SQL definition — edit this |
database/migrations/{timestamp}_create_order_summary_view.php |
Runs the SQL on migrate |
app/Models/Views/OrderSummaryView.php |
Eloquent model backed by the view |
The output path for view models is controlled by rome.readonly_model_path.
Regenerating views
Re-runs all .sql files in db_views_path against each configured connection, handling drop-and-recreate and view dependencies.
If some views depend on others existing first, declare them in priority_views in the config — they are created in the listed order before all remaining views (which are sorted alphabetically):
'priority_views' => ['base_metrics', 'aggregated_totals'],
# all views, all configured connections php artisan dbview:regen # single view php artisan dbview:regen order_summary # skip materialized views php artisan dbview:regen --no-materialized # preview which views would run without executing any SQL php artisan dbview:regen --dry-run
Multi-tenant mode
When tenant_model is configured, --multi-tenant iterates over all active tenants using eachCurrent (compatible with spatie/laravel-multitenancy):
# all active tenants php artisan dbview:regen --multi-tenant # specific tenants php artisan dbview:regen --tenants=abc123,def456
Refreshing materialized views (PostgreSQL only)
Via the job
use Splitstack\Rome\Jobs\RefreshMaterializedView; // Basic dispatch RefreshMaterializedView::dispatch(viewName: 'order_summary_view'); // Concurrent refresh (requires a unique index on the view) RefreshMaterializedView::dispatch( viewName: 'order_summary_view', concurrent: true, ); // Explicit connection and tenant context RefreshMaterializedView::dispatch( viewName: 'order_summary_view', concurrent: true, tenantId: $tenant->id, // scopes the dedup lock; does not perform tenant switching connection: 'analytics', // overrides rome.db_connections ); // Custom failure callbacks (closures are serialized automatically) RefreshMaterializedView::dispatch( viewName: 'order_summary_view', onFailure: [ fn (\Throwable $e, $job) => \Sentry\captureException($e), fn (\Throwable $e, $job) => Notification::send($admin, new ViewRefreshFailed($job->viewName, $e)), ], );
The job includes a distributed lock so concurrent dispatches for the same view/tenant are deduplicated rather than stacked.
Job defaults: 3 tries, 5-minute timeout, 60-second backoff.
Directly
use Splitstack\Rome\Database\MaterializedViewRefresher; (new MaterializedViewRefresher('analytics'))->refresh('order_summary_view', concurrent: true);
RefreshableMaterializedView trait
Add to any model backed by a materialized view for convenience dispatch methods:
use Splitstack\Rome\Concerns\RefreshableMaterializedView; class OrderSummaryView extends ReadOnlyModel { use RefreshableMaterializedView; } // Queue a refresh OrderSummaryView::queueRefresh(concurrent: true, queue: 'low'); // Queue a refresh with tenant context (tenant switching is the caller's responsibility) OrderSummaryView::queueRefresh(tenantId: $tenant->id, connection: 'analytics'); // Queue a delayed refresh OrderSummaryView::queueRefreshIn(seconds: 30, concurrent: true, queue: 'low'); // Dispatch synchronously (blocks until complete, goes through the job's lock + logging) OrderSummaryView::refreshNow(concurrent: true);
ViewDialect
Driver-aware SQL builder. Used internally but available if you need to generate view DDL yourself:
use Splitstack\Rome\Database\ViewDialect; $dialect = ViewDialect::fromConnection('analytics'); $dialect->driver(); // 'pgsql' | 'mysql' $dialect->supportsMaterializedViews(); // true on pgsql, false on mysql $dialect->dropView('order_summary_view'); // driver-appropriate DROP VIEW $dialect->dropMaterializedView('...'); // pgsql only, throws on mysql $dialect->refreshMaterializedView('...', true); // REFRESH ... CONCURRENTLY $dialect->uniqueIndexSql(); // pg_indexes / information_schema query
Database support
| Feature | PostgreSQL | MySQL |
|---|---|---|
| Regular views | ✓ | ✓ |
| Materialized views | ✓ | — (skipped with warning) |
DROP VIEW … CASCADE |
✓ | ✓ (omitted) |
| Unique index check | ✓ | ✓ |
PHPStan rules
Laravel Rome ships two PHPStan rules that catch misuse of ReadOnlyModel at static-analysis time — before a test or request ever hits the line.
Setup
Require PHPStan if you haven't already:
composer require --dev phpstan/phpstan
Then include the extension — either the published file or directly from vendor (see Publishing).
Rules
NoDirectWriteOnReadOnlyModelRule
Flags any call to save() or delete() on a ReadOnlyModel subclass. Both methods always throw ReadOnlyModelException at runtime; this surfaces the mistake at build time instead.
$summary = OrderSummaryView::find($id); $summary->save(); // ❌ PHPStan: Cannot call save() on OrderSummaryView: this is a ReadOnlyModel.
ProxiedWriteAfterProxyCallRule
Flags save() or delete() chained directly onto proxied() or underlying(false). Both return an in-memory instance hydrated from the view's attributes, which may contain computed column values that don't exist in the backing table. Writing through such an instance can silently corrupt data.
$summary->proxied()->save(); // ❌ PHPStan: Do not call save() on the result of proxied()… $summary->underlying(false)->save(); // ❌ PHPStan: Do not call save() on the result of underlying(false)… $summary->underlying(forceFetch: false)->save(); // ❌ same $summary->underlying(true)->save(); // ✓ DB-fetched — safe $summary->update(['status' => 'x']); // ✓ correct write path
The rule catches chained calls only. Assigning the result to a variable first (
$p = $view->proxied(); $p->save()) is not currently detected.
License
MIT
