splitstack/laravel-rome

Maintainers

Package info

github.com/EmilienKopp/laravel-rome

pkg:composer/splitstack/laravel-rome

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-06-03 03:58 UTC

This package is auto-updated.

Last update: 2026-06-03 03:58:59 UTC


README

Tests PHP Version Laravel Version Total Downloads

Make Database Views first-class citizens in your Laravel app.

A Laravel package for managing database views: scaffolding, regeneration, and materialized view refresh. Supports PostgreSQL and MySQL, with optional multi-tenant iteration.

Requirements

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

Installation

composer require splitstack/laravel-rome

Publish the config:

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

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',
];

Scaffolding a view

php artisan make:dbview order_summary

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

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

ReadOnlyModel

A base Eloquent model for views. Reads behave exactly like any Eloquent model. Direct writes (save, delete) always throw. Write operations that need to mutate data are proxied through a separate writable model that maps to the underlying table.

Setup

use Splitstack\Rome\Models\ReadOnlyModel;

class OrderSummaryView extends ReadOnlyModel
{
    protected $table = 'order_summary_view';

    // Declare the writable model that owns the underlying data
    protected static $proxiedModelClass = Order::class;
}

Primary key: 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 proxied model must use the same primary key name and type, since all proxy lookups use $this->getKey() to locate the record in the proxied table.

Proxied writes

update(array $attributes)

Finds the matching record in the proxied model by primary key, calls update() on it, then re-fetches and returns the view record so computed columns reflect the change.

$summary = OrderSummaryView::find($id);
$summary->update(['status' => 'shipped']); // returns OrderSummaryView

Throws if no matching record exists in the proxied table.

Accessing the underlying model

underlying(bool $forceFetch = false)

Returns a proxied model instance populated with the current record's data.

Without forceFetch (default): hydrates the instance in-memory from the view's attributes, intersected with the proxied model's $fillable. No database query is made, but attributes not listed in $fillable will be absent.

$order = $summary->underlying(); // no query; only $fillable attributes populated
$order->status; // available if 'status' is in Order::$fillable

With forceFetch: true: queries the proxied model's table directly. All attributes are present, at the cost of an extra query.

$order = $summary->underlying(forceFetch: true); // hits the database

Use the default when you only need fillable attributes (e.g. to re-dispatch events or call methods). Use forceFetch when you need the complete record or attributes that aren't in $fillable.

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

License

MIT