nisalatp / dynamicreportgenerator
Metadata-centric dynamic reporting engine for Laravel with automatic BFS joins, virtual attributes, and attribute-level security (privacy-aware masking).
Package info
github.com/nisalatp/DynamicReportGenerator-Public
pkg:composer/nisalatp/dynamicreportgenerator
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0
- phpunit/phpunit: ^11.0
README
Hi there! π Welcome to the Dynamic Report Generator β a reporting engine that lets your non-technical users build their own complex, multi-table reports, safely, without you writing a single query for them.
If you're a Laravel developer, you've been here: you ship a beautiful dashboard, and immediately get hit with "can we add a column for the user's last order date? what about filtering by customers who spent over $500 but haven't logged in recently?" Before long you're writing endless LEFT JOINs and a new controller for every report request. It's a bottleneck. This package removes it.
π¦ Live on Packagist Β Β·Β π Hosted documentation Β Β·Β β Independently built & validated by a third-party developer using only the published package and its docs.
The point-and-click report builder (from the bundled iStore demo) β pick a model and columns; the engine resolves the joins and compiles the SQL for you.
β¨ Why You'll Love It
- No more manual joins. A Graph-Theory Breadth-First Search reads your Eloquent relationships and finds the shortest join path between models automatically. You declare what you want; the engine works out how to join it.
- Virtual Attributes (the secret sauce). Pre-register a heavy SQL expression β say, a user's lifetime value β as a named "Virtual Attribute". To your end-users it's just another column to tick.
- Memory-efficient by design. The engine compiles your request and pushes the work down to the database; it does not hydrate thousands of Eloquent models into PHP just to aggregate them. Virtual Attributes run as correlated subqueries β O(1) application memory, whatever the row count.
- Predictable. Call
explainJoinPlan()to inspect the resolved JOIN path, ortoRawSql()to see the exact compiled query β before you run it. - Secure by default. Built-in attribute-level security β mask or block any column per user or role β alongside report assignment and full audit logging.
- 100% UI-agnostic. Vue, React, Livewire, AlpineJS, a Java client β even an LLM via the Model Context Protocol. They all speak the same JSON AST.
π¦ Installation
composer require nisalatp/dynamicreportgenerator
Requires PHP 8.2+ and Laravel 11, 12 or 13. The Service Provider and the DynamicReport facade are auto-discovered β no manual registration needed.
php artisan migrate # creates the dynamic_* tables (saved reports, logs, virtual attributes)
Then list the models you want to expose for reporting in config/dynamicreportgenerator.php under reportable_models. See the docs for the full setup.
π§ Architecture: a deterministic Build β Save β Run lifecycle
The engine deliberately separates what a report is (configuration) from running it (execution). A report definition is a strictly-typed Abstract Syntax Tree (AST) β never raw SQL β so the same definition always compiles to the same parameterised query. That separation is what lets you save, share and re-run reports predictably at scale.
| Phase | What happens | Key API |
|---|---|---|
| Build | A frontend (or the fluent ReportBuilder) produces a typed AST: base model, join targets, columns, filters, group-bys, aggregates. No SQL. |
ReportBuilder, ReportRequest |
| Save | The AST is persisted as a JSON payload (DB-agnostic β MySQL, PostgreSQL or SQLite), can be assigned to users, and every execution is audit-logged. | saveReport(), assignReport(), getReportLogs() |
| Run | The engine compiles the AST: BFS resolves the joins, Virtual Attributes are injected as subqueries, values are PDO-bound, and you get an Eloquent query back. | generate(), generatePaginated(), loadAndGenerate(), exportToCsv() |
π Quick Start
1. Build and run a report
use App\Models\{User, Order}; use Nisalatp\DynamicReportGenerator\Builders\ReportBuilder; use Nisalatp\DynamicReportGenerator\Facades\DynamicReport; $request = ReportBuilder::forModel(User::class) ->withTarget(Order::class) // BFS finds & builds the UserβOrder join for you ->select(User::class, 'name') ->select(Order::class, 'status') ->filter(fn ($f) => $f->where(Order::class, 'status', 'completed')) ->build(); return DynamicReport::generate($request)->paginate(50);
No JOIN, no DB::raw, no per-report controller.
2. See exactly what it will do β before you run it
$plan = DynamicReport::explainJoinPlan($request); // the resolved JOIN path $sql = DynamicReport::toRawSql(DynamicReport::generate($request)); // the compiled SQL
3. Save a definition, then re-run it later
$saved = DynamicReport::saveReport('Completed orders by user', $request, userId: auth()->id()); // elsewhere β load the saved configuration and execute it (writes an audit-log entry) return DynamicReport::loadAndGenerate($saved->id, executedByUserId: auth()->id())->paginate(50);
4. Virtual Attributes β expose heavy SQL as a simple column
Register a Virtual Attribute once (in a Service Provider or an admin screen); users then select it like any other column.
use Nisalatp\DynamicReportGenerator\Builders\VirtualAttributeBuilder; VirtualAttributeBuilder::create('Lifetime Value') ->forBaseModel(User::class) ->dependsOn([Order::class]) ->withSqlFragment('(SELECT COALESCE(SUM(amount), 0) FROM orders WHERE orders.user_id = {THIS}.id)') ->register();
β οΈ Reference the base table with the
{THIS}placeholder. The engine substitutes{THIS}with the base model's compiled query alias at run time (e.g.orders.user_id = {THIS}.id), so the fragment stays correct wherever the model sits in the join β don't hard-code the table name.
Then your users just select it like a normal column:
->select(User::class, 'Lifetime Value', 'integer', isVirtual: true)
π Attribute-Level Security (ALS)
Beyond report assignment, the engine ships with a column-level security firewall. Restrict any attribute for a subject (a user, role, team, β¦) in one of two modes:
maskedβ the column may still be selected, but its values are redacted in the output.blockedβ the column is off-limits; using it in a filter, grouping, aggregate, or sort raises aReportMakerSecurityException(blockedalways wins overmasked).
use App\Models\{User, Role}; use Nisalatp\DynamicReportGenerator\Facades\DynamicReport; DynamicReport::restrictAttribute(User::class, 'salary', $supportRole, 'masked'); // redact DynamicReport::restrictAttribute(User::class, 'ssn', $supportRole, 'blocked'); // forbid
Enforcement is automatic at generation time. Pass the subjects explicitly, or let the engine resolve them from the authenticated user:
// explicit subjects $rows = DynamicReport::generate($request, [$supportRole])->paginate(50); // implicit β if your User implements DynamicReportSubject, the engine calls // $user->getDynamicReportSubjects() for the logged-in user automatically $rows = DynamicReport::generate($request)->paginate(50);
There's also model-level restriction β restrictModel(Model::class) hides an entire model from the report surface. Per-framework walkthroughs live in the Attribute-Level Security examples.
πΌοΈ See it in action
All of the screens below run on the published engine β captured from the bundled iStore demo app.
Live results β the Data Preview returns real rows in-page, ready to export to CSV:
Attribute-Level Security β a per-role column matrix; here email is Masked and password / remember_token are Blocked:
AI-driven reporting (MCP) β ask a question in natural language and get a result set back, under the same security rules:
π Security by design
- Input is a typed JSON AST, validated at the boundary β raw user SQL never reaches the compiler.
- All filter values are transmitted as PDO parameter bindings.
- Aggregate functions are checked against an immutable whitelist (
SUM,AVG,COUNT,MIN,MAX).
An independent review judged the engine "inherently secure against SQL injection."
π Frontend-agnostic & AI-ready
One JSON AST drives every client β Blade/AlpineJS, Vue, React, a Java service. Because it's plain JSON, the AST is also natively compatible with the Model Context Protocol (MCP), so an LLM can turn a natural-language question straight into a valid report request. Runnable examples for each ecosystem live in the hosted docs, alongside the full AST reference.
β Tested & validated
Covered by a PHPUnit 11 + Orchestra Testbench suite (112 tests, in-memory SQLite) spanning BFS join resolution, AST compilation, Virtual Attributes, Attribute-Level Security, SQL-injection binding and persistence. Independently, a third-party developer installed the published package into a clean Laravel app and ran the full suite β 112 of 112 passing, including 24/24 Attribute-Level Security tests β and reported it "a robust, production-ready reporting engine."
π€ Contributing
This project is free and open-source. Issues and pull requests are welcome β whether that's NoSQL exploration, optimising the graph traversal, new frontend examples, or fixing a typo. Fork it, make your change, and open a PR.
π License
Open-sourced under the MIT license. Built by Nisala Aloka Bandara.




