PHPackages                             splitstack/laravel-rome - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. splitstack/laravel-rome

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

splitstack/laravel-rome
=======================

66PHPCI failing

Since Jun 3Pushed 1w agoCompare

[ Source](https://github.com/EmilienKopp/laravel-rome)[ Packagist](https://packagist.org/packages/splitstack/laravel-rome)[ RSS](/packages/splitstack-laravel-rome/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (1)DependenciesVersions (2)Used By (0)

 [![Laravel Rome](art/logo.png)](art/logo.png)

Laravel Rome
============

[](#laravel-rome)

[![Tests](https://camo.githubusercontent.com/fdf96706ef38dc68c25163d76d43bdbcb3d33d6d9bedbf1e1671c34ed99c69c3/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f656d696c69656e6b6f70702f6c61726176656c2d726f6d652f74657374732e796d6c3f6c6162656c3d7465737473)](https://camo.githubusercontent.com/fdf96706ef38dc68c25163d76d43bdbcb3d33d6d9bedbf1e1671c34ed99c69c3/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f656d696c69656e6b6f70702f6c61726176656c2d726f6d652f74657374732e796d6c3f6c6162656c3d7465737473)[![PHP Version](https://camo.githubusercontent.com/2b772b0e9daa8f19f7ae8cbbd897a75f9daecc22ee83ef8865e569c50ac3565a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d253545382e322d626c75652e7376673f7374796c653d666c61742d737175617265)](https://camo.githubusercontent.com/2b772b0e9daa8f19f7ae8cbbd897a75f9daecc22ee83ef8865e569c50ac3565a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d253545382e322d626c75652e7376673f7374796c653d666c61742d737175617265)[![Laravel Version](https://camo.githubusercontent.com/e06d835966e2c7d5e5f60b16d88a3ec1f1d16c0f58667b981d4a769a66a28859/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c61726176656c2d25354531312e302d6f72616e67652e7376673f7374796c653d666c61742d737175617265)](https://camo.githubusercontent.com/e06d835966e2c7d5e5f60b16d88a3ec1f1d16c0f58667b981d4a769a66a28859/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c61726176656c2d25354531312e302d6f72616e67652e7376673f7374796c653d666c61742d737175617265)[![Total Downloads](https://camo.githubusercontent.com/2ff0f8153a9fe1047e6c64a42c6c3c39f1da928681de875e20480341a8c16dc1/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f73706c6974737461636b2f6c61726176656c2d726f6d652e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/splitstack/laravel-rome)

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
------------

[](#requirements)

- PHP 8.2+
- Laravel 11+
- Database: PostgreSQL 9.3+ or MySQL 5.7+

Installation
------------

[](#installation)

```
composer require splitstack/laravel-rome
```

Publishing
----------

[](#publishing)

Publish the Laravel config:

```
php artisan vendor:publish --tag=rome-config
```

Publish the PHPStan extension (optional — see [PHPStan rules](#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
-------------

[](#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)

`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

[](#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

[](#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'];
}
```

PropertyTypePurpose`$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](#danger-computed-columns-that-share-a-name-with-the-underlying-table-column)`$primaryKey``string` (default: `id`)The primary key column name. Override if your view's primary key is different.#### Primary Key configuration

[](#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

[](#proxy-operations)

#### `update(array $attributes)`

[](#updatearray-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()`

[](#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

[](#accessing-the-underlying-model)

#### `underlying(bool $forceFetch = true)`

[](#underlyingbool-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()`

[](#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)

`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()`

[](#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()`

[](#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)

`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
------------------

[](#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:

FilePurpose`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 viewThe output path for view models is controlled by `rome.readonly_model_path`.

Regenerating views
------------------

[](#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

[](#multi-tenant-mode)

When `tenant_model` is configured, `--multi-tenant` iterates over all active tenants using `eachCurrent` (compatible with [spatie/laravel-multitenancy](https://github.com/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)
-----------------------------------------------

[](#refreshing-materialized-views-postgresql-only)

### Via the job

[](#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

[](#directly)

```
use Splitstack\Rome\Database\MaterializedViewRefresher;

(new MaterializedViewRefresher('analytics'))->refresh('order_summary_view', concurrent: true);
```

### RefreshableMaterializedView trait

[](#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
-----------

[](#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
----------------

[](#database-support)

FeaturePostgreSQLMySQLRegular views✓✓Materialized views✓— (skipped with warning)`DROP VIEW … CASCADE`✓✓ (omitted)Unique index check✓✓PHPStan rules
-------------

[](#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

[](#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](#publishing)).

### Rules

[](#rules)

#### `NoDirectWriteOnReadOnlyModelRule`

[](#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`

[](#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
-------

[](#license)

MIT

###  Health Score

24

—

LowBetter than 31% of packages

Maintenance64

Regular maintenance activity

Popularity11

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity13

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/b9e77e55bb8341a1fa99f8348597070b4de230b5fc457e866e4f196dd56843f1?d=identicon)[EmilienKopp](/maintainers/EmilienKopp)

---

Top Contributors

[![EmilienKopp](https://avatars.githubusercontent.com/u/91975560?v=4)](https://github.com/EmilienKopp "EmilienKopp (26 commits)")

### Embed Badge

![Health badge](/badges/splitstack-laravel-rome/health.svg)

```
[![Health](https://phpackages.com/badges/splitstack-laravel-rome/health.svg)](https://phpackages.com/packages/splitstack-laravel-rome)
```

###  Alternatives

[markshust/magento2-module-simpledata

The SimpleData module simplifies calling Magento data structures.

94192.7k2](/packages/markshust-magento2-module-simpledata)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
