Search by

aldeebhasan / panorama

aldeebhasan

One wide view across every source: define filterable Laravel reports once, then aggregate, cache and generate them anywhere.

Package info

github.com/aldeebhasan/panorama

pkg:composer/aldeebhasan/panorama

Fund package maintenance!

aldeebhasan

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.4.0 2026-10-18 22:34 UTC

This package is auto-updated.

Last update: 2026-10-18 22:40:34 UTC


README

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

Reporting data is spread across application databases, external APIs and files, and every report re-implements fetching, cleaning and joining it.

Panorama lets you define each source once, as a dataset that fetches its own rows and declares the exact shape it guarantees, and then build reports that use it alone or merge it with any other dataset.

The mental model in one line: datasets provide the rows, and a report is a query over them.

Datasets::from('orders')              // rows from a database table
    ->join('crm_customers')           // rows from an HTTP API; the join key is inferred
    ->groupBy('crm_customers.name', ['revenue' => Aggregate::sum('orders.amount')])
    ->get();

Panorama does not reach your sources for you. A dataset fetches with whatever the application already uses: a query builder cursor, the HTTP client, an Eloquent model. The merge runs in PHP, so there is no database the package needs to write to.

Requirements

  • PHP 8.2+
  • Laravel 11, 12 or 13

Installation

composer require aldeebhasan/panorama
php artisan vendor:publish --tag="panorama-config"

The three concepts

Concept Responsibility Example
Dataset Fetches rows from one source, and declares their schema. The reusable unit. orders, crm_customers
Schema The contract of a dataset: fields, types, key, field mapping, relations. customer_id is a string referencing crm_customers.id
Report Queries one dataset or merges several, then renders and delivers the result. revenue by customer, as CSV

Datasets

php artisan make:dataset Orders

A dataset fetches its own rows and says what shape they are guaranteed to have:

use Aldeebhasan\Panorama\Dataset\Dataset;
use Aldeebhasan\Panorama\Dataset\Params;
use Aldeebhasan\Panorama\Schema\Schema;
use Illuminate\Support\Facades\DB;

class Orders extends Dataset
{
    public string $name = 'orders';

    // Filters this dataset accepts, with their validation rules.
    public function parameters(): array
    {
        return ['from' => ['date'], 'to' => ['date']];
    }

    public function fetch(Params $params): iterable
    {
        // Pushdown: the filters narrow the fetch, not the result.
        return DB::table('orders')
            ->whereBetween('created_at', [$params->from, $params->to])
            ->cursor();
    }

    public function schema(): Schema
    {
        return Schema::make()
            ->primaryKey('id')
            ->int('id')
            ->string('customer_id')->references('crm_customers.id')
            ->decimal('amount', 2)->currency('USD')
            ->date('created_at');
    }
}

And one reading an API, whose field names differ:

class CrmCustomers extends Dataset
{
    public string $name = 'crm_customers';

    public function fetch(Params $params): iterable
    {
        for ($page = 1; ; $page++) {
            $rows = Http::withToken(config('services.crm.token'))
                ->get(config('services.crm.url') . '/customers', ['updated_since' => $params->from, 'page' => $page])
                ->throw()
                ->json('data');

            if ($rows === []) {
                return;
            }

            yield from $rows;
        }
    }

    public function schema(): Schema
    {
        return Schema::make()
            ->primaryKey('id')
            ->string('id', from: 'ext_id')
            ->string('name', from: 'custName')
            ->string('country')->nullable()
            ->datetime('created_at', from: 'createdAt')->timezone('UTC');
    }
}

Register them by listing or by discovery:

'datasets' => [
    'register' => [App\Reports\Datasets\Orders::class],
    'discover' => [app_path('Reports/Datasets') => 'App\Reports\Datasets'],
],

Datasets are looked up by name, so reports never depend on class names. Return a generator or a cursor from fetch() rather than an array, and a dataset of any size costs one row of memory. Any failure while fetching is rethrown as SourceUnavailable, naming the dataset.

Schema

Field types: string, int, decimal(scale), bool, date, datetime, json, enum(values).

Modifiers: nullable(), from('rawName'), default(), timezone($output, $source), currency(), references('dataset.field'), and primaryKey() on the schema.

Every row is processed in one fixed order: map raw field names → apply defaults → cast to the declared type → normalize → validate → apply the violation policy. Output rows carry the declared fields and nothing else.

Casts reject rather than guess. '1.5' on an int field fails instead of silently dropping the fraction, and a non-empty string on a bool field fails instead of following PHP's truthiness.

When a row breaks its contract, the dataset's policy decides:

Schema::make()->skipOnViolation()   // drop the row, and count it
Schema::make()->nullifyOnViolation()// null the value, and count it
Schema::make()->failOnViolation()   // the default: stop, naming dataset, field, row and reason
$dataset->log()->skipped();      // rows dropped
$dataset->log()->nullified();    // values nulled
$dataset->log()->violations();   // a sample of the reasons

Querying and merging

A report merges datasets with a fluent query that runs in PHP:

use Aldeebhasan\Panorama\Facades\Datasets;
use Aldeebhasan\Panorama\Query\Aggregate;

// One dataset, standalone.
Datasets::from('orders')->params(['from' => '2026-01-01'])->get();

// Join on a shared column: keys inferred from the references() the schemas declare...
Datasets::from('orders')
    ->join('crm_customers')
    ->groupBy('crm_customers.name', [
        'revenue' => Aggregate::sum('orders.amount'),
        'orders' => Aggregate::count(),
    ])
    ->orderBy('revenue', 'desc')
    ->get();

// ...or named, in either order.
Datasets::from('orders')->leftJoin('crm_customers', 'orders.customer_id', 'crm_customers.id');

// Append same-shaped datasets, tagging where each row came from.
Datasets::union('shop_a_sales', 'shop_b_sales')->tagSource('source')->get();

Columns are written field, or dataset.field when more than one dataset declares the field.

Joining join, leftJoin
Filtering, before grouping where, whereIn, whereNotIn, whereBetween, whereNull, whereNotNull, and filter(fn (array $row) => ...) for anything else, with the row keyed by dataset.field
Grouping groupBy($columns, $aggregates), aggregate($aggregates) for totals over every row, having on the output columns
Aggregates Aggregate::sum, avg, min, max, count(), count('field'), countDistinct
Shaping select('orders.id', 'crm_customers.name as customer'), orderBy, limit, offset
Running get(), first(), result() for a streamed result, loaded() for row counts per dataset

Checked before anything is fetched:

  • Every column must exist, and an unqualified one must belong to exactly one dataset in the query.
  • Join keys must have comparable declared types. Joining a string key against an int key fails with a clear error instead of returning an empty result that looks like "no data".
  • Union members must share field names and types.
  • sum and avg need a numeric field.

How it runs. The first dataset streams. Each joined dataset is read once and held in memory, indexed by its join key. Grouping and sorting hold their rows in memory too, as any engine has to. A query that neither groups nor sorts holds nothing but its joined datasets. Each dataset receives only the parameters it declares.

Semantics follow SQL. A null on either side of a comparison matches nothing, not in a list holding a null matches nothing, and aggregates skip nulls. like is case sensitive and ilike is not. Nulls sort first ascending and last descending, as in SQLite and MySQL. Decimals are summed exactly at their declared scale, so 19.99 + 30.00 is 49.99. With no select, every field of every dataset is returned, and a field name two datasets share comes out as dataset.field for both.

Reports

php artisan make:report RevenueByCustomer
use Aldeebhasan\Panorama\Dataset\Datasets;
use Aldeebhasan\Panorama\Query\Aggregate;
use Aldeebhasan\Panorama\Query\DatasetQuery;
use Aldeebhasan\Panorama\Report\Column;
use Aldeebhasan\Panorama\Report\Report;

class RevenueByCustomer extends Report
{
    public function parameters(): array
    {
        return [
            'from' => ['required', 'date'],
            'to' => ['required', 'date', 'after:from'],
        ];
    }

    public function query(Datasets $datasets): DatasetQuery
    {
        return $datasets->from('orders')
            ->join('crm_customers')
            ->groupBy('crm_customers.name', ['revenue' => Aggregate::sum('orders.amount')])
            ->orderBy('revenue', 'desc');
    }

    public function columns(): array
    {
        return [
            Column::make('name')->label('Customer'),
            Column::make('revenue')->money('USD')->total(),
        ];
    }

    public function authorize(mixed $user = null): bool
    {
        return $user?->can('view-finance-reports') ?? false;
    }
}

Register it in config/panorama.php under reports, then run it:

use Aldeebhasan\Panorama\Facades\Panorama;

$run = Panorama::run('revenue-by-customer', ['from' => '2026-01-01', 'to' => '2026-09-30']);

$run->get();                        // rows as an array
$run->rows();                       // rows as a generator
$run->toCsv()->download();          // a streamed response
$run->toCsv()->store('s3');         // writes it, returns the path
$run->toHtml()->download();         // a standalone page with the table
$run->toXlsx()->store('s3');        // an Excel workbook
$run->toJson()->contents();         // the document as a string

Parameters are validated against the rules the report declares. Pass a user as the third argument to run() and the report's own authorize() is enforced:

Panorama::run('revenue-by-customer', $filters, $request->user());

key() defaults to the kebab-cased class basename and name() to a readable form, so both are optional.

Columns

label(), money($currency, $decimals), number($decimals), percent($decimals), date($format), datetime($format), whenEmpty($placeholder), total(), and format(fn ($value, $row) => ...) for anything else.

A column carries two readings of the same cell, which is why one report renders correctly in every format:

  • CSV uses the display reading — a spreadsheet is opened by a person, so money, dates and percentages are formatted, and total() columns get a footer.
  • HTML uses the display reading too, as a standalone page with the table, a totals footer, and numeric columns right aligned. It carries its own stylesheet and loads nothing, so it opens the same from a download, a disk or a mail attachment. Every value is escaped.
  • XLSX writes number, money and percent columns as real numbers, so they can be summed, with percentages in Excel's percent format. Every other cell is the display reading, and total() columns get a totals row. Cells are plain text, so a value in a dataset is never read as a formula. It needs PHP's zip extension.
  • JSON uses the raw reading — another system wants 1234.56, not "1,234.56 USD".

To embed the bare table in a page of your own, or to style it yourself, register a configured renderer under html:

app(RendererRegistry::class)->register(new HtmlRenderer(document: false));
app(RendererRegistry::class)->register(new HtmlRenderer(stylesheet: file_get_contents(resource_path('css/report.css'))));

Queued runs

Large reports can be run by a queue worker instead of the request. Parameters are validated and the user authorized when the run is queued, so mistakes still surface to the caller. The worker renders the report, stores it on a disk, and notifies whoever asked:

Panorama::queue('revenue-by-customer', $filters, $request->user())
    ->format('csv')                 // csv unless given; checked now
    ->store('s3', 'exports')        // the configured storage disk and path unless given
    ->notify($request->user())      // any notifiable, or Notification::route('mail', 'finance@example.com')
    ->onQueue('reports')            // also onConnection() and delay()
    ->dispatch();
  • ReportReady is sent once the report is stored. Reports up to queue.attach_up_to bytes (10 MB) are attached to the mail. Larger ones are named by the path they were stored at.
  • ReportFailed is sent after the job's last attempt fails. The mail says that it failed, not why, because the reason can name tables or hosts its recipient should not see. The reason is kept in the failed job and the run history.
  • The user is authorized again in the worker, so a permission revoked while the job waited is honoured. Models, both the user and the notifiables, are queued as identifiers and fetched fresh.
  • A report must be registered, or be a named class, to be queued. An anonymous class cannot be rebuilt by a worker.

Channels default to mail. Change them with queue.channels, and replace the wording by pointing queue.notifications.ready / failed at classes extending Notifications\ReportReady / ReportFailed.

Scheduled runs

Schedule a report in routes/console.php. It is registered on Laravel's scheduler, so every frequency and constraint a scheduled event takes can be chained on it, and it shows up in schedule:list as Report [revenue-by-customer]:

use Aldeebhasan\Panorama\Facades\Panorama;
use Carbon\CarbonImmutable;

Panorama::schedule('revenue-by-customer')
    ->params(fn (CarbonImmutable $now) => [
        'from' => $now->subMonthNoOverflow()->startOfMonth()->toDateString(),
        'to' => $now->subMonthNoOverflow()->endOfMonth()->toDateString(),
    ])
    ->format('csv')
    ->store('s3', 'exports/monthly')
    ->notify(fn () => User::where('team', 'finance')->get())
    ->monthlyOn(1, '07:00')
    ->timezone('Europe/Berlin')
    ->onOneServer();

Each time it fires, the report is queued as with Panorama::queue(), and the same notifications follow.

  • params() takes an array, or a closure given the moment the schedule fired, in the schedule's timezone.
  • notify() takes notifiables, lists of them, or closures returning either. Closures are called when the schedule fires, so recipients added later are included.
  • Each run's file is stamped with that moment and a hash of its parameters, revenue-by-customer-2026-10-01-070000-a253e820.csv, so runs do not overwrite each other, even two schedules of the same report firing together. named() takes a string, or a closure given the same moment.
  • Scheduled runs have no user, so a report's authorize() is not consulted.
  • A run that cannot even be queued, for example because its parameters no longer validate, sends ReportFailed to the same notifiables and is reported as a failed scheduled task.
  • The report and the format are checked where the schedule is defined, so a typo fails on deploy rather than the first time the schedule fires.
  • Two schedules of the same report share the name Report [key]. Give one its own ->name() before using onOneServer() or withoutOverlapping().

Caching

A report that sets $cacheFor keeps its rows for that many seconds. A second run with the same parameters, in the browser, a queued job or a schedule, reads them back without touching the datasets:

class RevenueByCustomer extends Report
{
    protected int $cacheFor = 3600;
}
  • Rows are kept per report and set of validated parameters, in the store named by cache.store, the default one unless set.
  • They are cached once they have been read to the end. A result longer than cache.max_rows, 10,000 by default, is streamed as usual and not cached, so a large report is never held in memory or in the store.
  • The user is not part of the key. Do not cache a report whose datasets return different rows for different users.
  • To read fresh rows before the time is up, forget them: Panorama::forget('revenue-by-customer', $params).

History

Every rendered report can be recorded in a report_runs table: which report, with which parameters, for which user, in which format, where it went, how many rows and bytes, how long it took, and why it failed. It is off by default.

php artisan vendor:publish --tag="panorama-migrations"
php artisan migrate
PANORAMA_HISTORY=true

A run is inserted as running when delivery begins and updated to succeeded or failed once it settles. A stored report settles after the write and records the path it took. A download settles when its stream ends, not when the response is built. A run abandoned half way is recorded as failed. Reading rows with get() or rows() renders nothing, so it records nothing.

Read it back with the RecordedRun model:

use Aldeebhasan\Panorama\History\RecordedRun;

RecordedRun::forReport('revenue-by-customer')->failed()->latestFirst()->limit(10)->get();

reports:prune deletes runs older than history.keep_days (90 by default), so it is worth scheduling:

Schedule::command('reports:prune')->daily();

To keep history somewhere else, bind your own History\RecorderContract.

Console

php artisan datasets:list                                   # datasets, keys and relations
php artisan datasets:describe orders --sample=5              # schema, parameters and a sample of rows
php artisan reports:list                                     # reports, their parameters and datasets
php artisan reports:run revenue-by-customer --param=from=2026-01-01 --param=to=2026-09-30 --format=html
php artisan reports:run revenue-by-customer --store --disk=s3
php artisan reports:history revenue-by-customer --failed --limit=10   # the recent runs, newest first
php artisan reports:prune --days=30                          # delete older runs
php artisan make:dataset CrmCustomers
php artisan make:report RevenueByCustomer

Testing

Replace any source with fixture rows. The real schema still applies, so a fixture that does not honour the contract fails exactly as the real source would:

Datasets::fake('crm_customers', [
    ['ext_id' => 'c-1', 'custName' => 'Ada', 'country' => 'DE'],
]);
composer test

The suite needs nothing installed: the one dataset that reads a real table does so from SQLite in memory.

Extending

Every stage is an interface, and the built-ins are ordinary implementations:

To add Implement Register
A format (PDF, ...) Render\RendererContract RendererRegistry::register()
A destination (mail, Slack) Deliver\DestinationContract pass it to RenderedReport::to()
A source extend Dataset\Dataset the datasets config
A history store History\RecorderContract bind it in the container
A field type's casting Schema\Casts\Cast pass it to Schema\Processor

Roadmap

Not yet shipped: a PDF renderer, snapshots and incremental sync.

Changelog

Please see CHANGELOG for more information on what has changed recently.

Credits

License

The MIT License (MIT). Please see License File for more information.