splitstack/laravel-rome

Maintainers

Package info

github.com/EmilienKopp/laravel-rome

pkg:composer/splitstack/laravel-rome

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

dev-main 2026-06-21 10:20 UTC

This package is auto-updated.

Last update: 2026-06-21 10:33:12 UTC


README

Laravel Rome

Laravel Rome

Tests PHP Version Laravel Version Total Downloads

Make database views first-class citizens in your Laravel app.

Laravel Rome gives you three complementary tools:

  • HasReadOnlyMode trait — add to any existing Eloquent model to get a readonly() guard on instances and a fromView() 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 with dbview: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 a ReadOnlyModel instance 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 $proxyTo entirely.

Enabling proxy operations

Proxy operations (update, underlying, proxied) are off by default.

To use them, you should:

  1. Turn on the global switch — set rome.proxy_enabled => true in config/rome.php or, if you don't need to publish, set the environment variable ROME_PROXY_ENABLED=true.
  2. 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() and underlying(forceFetch: false) will silently hydrate the proxied instance with the computed value from the view, not the raw stored value. Calling save() or update() on that instance can then write the computed value back to the table, corrupting data.

Example: a view computes total_price as quantity * unit_price. The orders table also has a stored total_price column. Calling proxied() populates $order->total_price with the view-computed figure. If that instance is then updated, the computed figure overwrites the stored one.

We provide a php artisan rome:check command 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 $exclude list 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 orders

When renaming is not possible (e.g. the view is shared or generated), use $exclude to 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'];
}

$exclude has no effect on underlying(forceFetch: true), which always reads from the database. Use forceFetch: true (the default) whenever you intend to write back through the proxied model. Only use proxied() or underlying(false) when you explicitly do not need the stored values and have audited both your column aliases and your $exclude list.

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 $readOnlyView when set, otherwise from the model's own table
  • wraps every result (get, first, sole, find) in a ReadOnlyProxy
  • throws ReadOnlyModelException immediately on update(), delete(), create(), or firstOrCreate()
// 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