Search by

saroven / laravel-reportify

saroven

Unified PDF, Excel, CSV, TXT export engine for Laravel applications.

Package info

github.com/saroven/laravel-reportify

pkg:composer/saroven/laravel-reportify

Statistics

Installs: 13

Dependents: 0

Suggesters: 0

Stars: 3

Open Issues: 0

v1.1.0 2026-09-24 04:34 UTC

This package is auto-updated.

Last update: 2026-09-24 04:36:25 UTC


README

Latest Version on Packagist GitHub Tests Action Status Total Downloads License Demo Repository

Reportify is a unified, high-performance report generation and document export engine for Laravel applications. Easily stream or export PDFs (via mPDF), Excel (.xlsx), CSV, TXT, and ZIP archives using clean Laravel syntax, event-driven background queues, and customizable Blade templates.

๐ŸŽฎ Demo Application

A complete working demo showing User Directory exports, PDF streaming, and a full Download Manager lifecycle implementation is available at:

๐Ÿ‘‰ https://github.com/saroven/laravel-reportify-demo

# Clone and run the demo locally
git clone https://github.com/saroven/laravel-reportify-demo.git
cd laravel-reportify-demo
composer install
php artisan migrate:fresh --seed
php artisan serve

๐Ÿ“ฆ Features

  • ๐Ÿ“‘ Multi-Format Export Engine: Generate PDF, Excel (.xlsx), CSV, TXT, and ZIP packages.
  • โšก Synchronous PDF Streaming: Directly stream formatted PDF documents in the browser tab.
  • ๐Ÿ”„ Event-Driven Background Queues: Offload heavy exports to queue workers with native Laravel events (ExportStarted, ExportCompleted, ExportFailed).
  • ๐Ÿงฉ PDF Chunking & Merging: Automatically chunk large datasets into smaller PDF files and merge them via PDFMerger.
  • ๐ŸŽจ Customizable Blade Templates: Configurable PDF headers, page footers, print dates, authenticated user stamps, and page numbers (Page X of Y).
  • ๐Ÿ›  Artisan Generator Command: php artisan reportify:make {name} generates clean Reportable export classes.
  • ๐ŸŽฎ Exportable Controller Trait: HasReportify trait enables 1-line export handling in controllers ($this->exportReport()).
  • ๐Ÿ”˜ Blade UI Component: Drop-in export action buttons <x-reportify-buttons /> and helper scripts <x-reportify-scripts />.

๐Ÿ“– About

This package started from a recurring problem: every Laravel project with reporting needs ends up with the same scattered code โ€” mPDF calls in controllers, memory crashes on large datasets, separate queue jobs per format, and footer/header logic copied between files.

Reportify pulls all of that into one place. You define what data to export via a Reportable class, pick a format, and the rest is handled โ€” chunking for large PDFs, queuing, event dispatching, Blade templates for headers and footers, and ZIP packaging. The same interface works for every format, so there's nothing new to learn when you add a second export type to a controller.

A few things worth knowing before you start:

  • pdfChunk is what you want for anything over a few thousand rows. It splits the data, renders parts separately, then merges them โ€” so mPDF never has to hold the full dataset in memory.
  • Exports are queued by default. If your server doesn't run a queue worker, set REPORTIFY_FORCE_SYNC=true and everything runs inline.
  • The Reportable interface has one method. If your controller already has the data, you can implement it directly on the controller and skip the separate export class entirely.
  • Header margins are auto-calculated from your header HTML. If the result is off, headerMargin and additionalHeaderMargin in $additionalData let you correct it per-report without touching the config.

โšก Installation

Install the package via Composer:

composer require saroven/laravel-reportify

Publish the configuration file and Blade views (optional):

php artisan vendor:publish --tag=reportify-config
php artisan vendor:publish --tag=reportify-views

๐Ÿš€ Quick Start

1. Make any Controller Exportable

Implement the Reportable interface and use the HasReportify trait on your controller:

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Saroven\Reportify\Contracts\Reportable;
use Saroven\Reportify\Traits\HasReportify;
use App\Exports\UserExport;
use App\Models\User;

class UserController extends Controller implements Reportable
{
    use HasReportify;

    public function index(Request $request)
    {
        // Intercept export requests (e.g. ?export=pdfStream or ?export=excel)
        if ($request->has('export')) {
            $view = in_array($request->get('export'), ['pdfStream', 'pdf']) ? 'reports.users-pdf' : null;
            return $this->exportReport($request, 'User Directory Report', view: $view, dataProvider: UserExport::class);
        }

        $users = User::latest('id')->paginate(10);
        return view('users.index', compact('users'));
    }

    public function getExportData(array $payload, string $exportType, int|string|null $userId = null): mixed
    {
        return User::query()->get();
    }
}

2. Generate Dedicated Export Classes

Generate a dedicated Reportable export class using the Artisan generator command:

php artisan reportify:make UserExport

This creates app/Exports/UserExport.php:

namespace App\Exports;

use App\Models\User;
use Saroven\Reportify\Contracts\Reportable;

class UserExport implements Reportable
{
    public function getExportData(array $payload, string $exportType, int|string|null $userId = null): mixed
    {
        $query = User::query();

        if (!empty($payload['search'])) {
            $search = $payload['search'];
            $query->where(function ($q) use ($search) {
                $q->where('name', 'like', "%{$search}%")
                  ->orWhere('email', 'like', "%{$search}%")
                  ->orWhere('phone', 'like', "%{$search}%");
            });
        }

        // Return clean mapped attributes for spreadsheet and plain text exports
        return $query->latest('id')->get()->map(function (User $user) {
            return [
                'ID' => $user->id,
                'Name' => $user->name,
                'Email' => $user->email,
                'Role' => $user->role,
                'Department' => $user->department ?? '-',
                'Phone' => $user->phone ?? '-',
                'Status' => $user->status,
                'Created At' => $user->created_at ? $user->created_at->format('Y-m-d H:i:s') : '-',
            ];
        });
    }
}

Dispatch background exports manually using ProcessReportJob:

use App\Exports\UserExport;
use Saroven\Reportify\Jobs\ProcessReportJob;

public function export(Request $request)
{
    ProcessReportJob::dispatch(
        requestData: $request->all(),
        type: 'users-report',
        title: 'Users Export',
        user: auth()->id(),
        view: 'reports.users-pdf',
        additionalData: ['orientation' => 'L'],
        dataProvider: UserExport::class
    );

    return back()->with('success', 'Export process started successfully.');
}

3. Synchronous PDF Streaming

Stream a generated PDF directly to the browser for inline preview or printing:

use Saroven\Reportify\Facades\Reportify;

public function print(Request $request)
{
    $users = User::where('status', 'active')->get();

    return Reportify::streamPdf(
        request: $request->all(),
        response: $users,
        title: 'Active Users List',
        type: 'active-users',
        view: 'reports.users-pdf',
        additionalData: [
            'orientation' => 'P',
            'paper_size' => 'A4',
            'headerHtml' => '<h2>Active Users Report</h2>'
        ]
    );
}

4. PDF Chunking & Large Dataset Processing (pdfChunk)

When exporting large datasets (e.g. 5,000 to 50,000+ records), rendering everything in a single mPDF memory buffer can trigger memory limit crashes or mPDF backtrack errors. Reportify solves this with PDF Chunking & Merging:

use Saroven\Reportify\Facades\Reportify;

// Export large dataset by automatically chunking & merging PDF parts
$pdfPath = Reportify::exportPdfChunk(
    request: $request->all(),
    response: $largeUserCollection,
    context: 'exports/pdf',
    title: 'Large User Directory Export',
    view: 'reports.users-pdf',
    additionalData: ['orientation' => 'P']
);

In Controllers via HasReportify:

Simply pass ?export=pdfChunk in request parameters:

if ($request->has('export')) {
    $view = in_array($request->get('export'), ['pdfStream', 'pdf', 'pdfChunk']) ? 'reports.users-pdf' : null;
    return $this->exportReport($request, 'User Directory Report', view: $view, dataProvider: UserExport::class);
}

How PDF Chunking Works:

  1. Splits records into batches based on config('reportify.chunk_size', 2000).
  2. Generates standalone PDF parts in temporary storage without memory overflow.
  3. Merges all chunked PDF parts into a single output PDF using mPDF's page template importer.
  4. Automatically cleans up temporary chunk files from storage.
  5. Stamps the footer (page numbers, print date, "printed by") once onto the merged file, so page numbering runs across the whole document. Your own hide* flags in $additionalData still apply to that final footer.

4. Direct Multi-Format Exports

Export directly using the Reportify Facade or reportify() global helper:

use Saroven\Reportify\Facades\Reportify;

// Export Excel (.xlsx)
$excelPath = Reportify::exportExcel($request->all(), $data, 'exports/excel', 'Users List', 'reports.users-table');

// Export CSV (.csv)
$csvPath = Reportify::exportCsv($request->all(), $data, 'exports/csv', 'Users List');

// Export Text File (.txt) โ€” first line is the column headings (row keys), columns joined by " | "
$txtPath = Reportify::exportTxt($request->all(), $data, 'exports/txt', 'Users List');

// Custom delimiter
$txtPath = Reportify::exportTxt($request->all(), $data, 'exports/txt', 'Users List', null, ['separator' => ',']);

// Export Multi-Part ZIP Package (.zip)
$zipPath = Reportify::prepareZip('pdf', $request->all(), $largeData, 'exports/zips', 'User Statements', 'reports.statement-pdf');

5. Listen to Export Events (Building a Download Manager)

Reportify dispatches native Laravel events during the export processing lifecycle (ExportStarted, ExportCompleted, ExportFailed). You can listen to these events in AppServiceProvider.php to track job statuses and build a Download Manager:

namespace App\Providers;

use App\Models\Download;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Event;
use Saroven\Reportify\Events\ExportStarted;
use Saroven\Reportify\Events\ExportCompleted;
use Saroven\Reportify\Events\ExportFailed;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // 1. Export Started -> Record initial 'processing' status with unique exportId
        Event::listen(function (ExportStarted $event) {
            Download::create([
                'export_id' => $event->exportId,
                'user_id'   => $event->userId ?: null,
                'title'     => $event->title,
                'format'    => strtoupper($event->exportFormat),
                'status'    => 'processing',
            ]);
        });

        // 2. Export Completed -> Update status to 'completed' matched by unique exportId
        Event::listen(function (ExportCompleted $event) {
            $download = $event->exportId
                ? Download::where('export_id', $event->exportId)->first()
                : Download::where('title', $event->title)->where('status', 'processing')->latest('id')->first();

            if ($download) {
                $download->update([
                    'file_path' => $event->filePath,
                    'status'    => 'completed',
                ]);
            }
        });

        // 3. Export Failed -> Update status to 'failed' with error details
        Event::listen(function (ExportFailed $event) {
            $download = $event->exportId
                ? Download::where('export_id', $event->exportId)->first()
                : Download::where('title', $event->title)->where('status', 'processing')->latest('id')->first();

            if ($download) {
                $download->update([
                    'status' => 'failed',
                    'error'  => $event->errorMessage,
                ]);
            }
        });
    }
}

Then create a DownloadController to serve the generated files:

namespace App\Http\Controllers;

use App\Models\Download;
use Illuminate\Support\Facades\Storage;

class DownloadController extends Controller
{
    public function index()
    {
        $downloads = Download::latest('id')->paginate(10);
        return view('downloads.index', compact('downloads'));
    }

    public function download(Download $download)
    {
        $disk = config('reportify.storage_disk', 'public');
        return Storage::disk($disk)->download($download->file_path);
    }
}

6. Add Export Buttons & Scripts to Blade Layouts

Include drop-in action buttons in your views:

<!-- Render export dropdown buttons -->
<x-reportify-buttons
    :pdfStream="['url' => '#', 'onClick' => 'exportLinkRedirectWithUrlParams(event, {type: `pdfStream`})']"
    :pdf="['url' => '#', 'onClick' => 'exportLinkRedirectWithUrlParams(event, {type: `pdf`})']"
    :excel="['url' => '#', 'onClick' => 'exportLinkRedirectWithUrlParams(event, {type: `excel`})']"
    :csv="['url' => '#', 'onClick' => 'exportLinkRedirectWithUrlParams(event, {type: `csv`})']"
    :txt="['url' => '#', 'onClick' => 'exportLinkRedirectWithUrlParams(event, {type: `txt`})']"
/>

Include <x-reportify-scripts /> in your master layout template (layouts/app.blade.php) for automatic query parameter preservation:

    @yield('content')

    <x-reportify-scripts />
</body>
</html>

โš™๏ธ Configuration Reference (config/reportify.php)

return [
    'storage_disk'     => env('REPORTIFY_STORAGE_DISK', 'public'),
    'force_sync'       => (bool) env('REPORTIFY_FORCE_SYNC', false),
    'export_directory' => 'exports',
    'chunk_size'       => (int) env('REPORTIFY_CHUNK_SIZE', 2000),

    'mpdf' => [
        'backtrack_limit'       => '1000000000',
        'recursion_limit'       => '1000000000',
        'default_paper_size'    => 'A4',
        'default_orientation'   => 'P',
        'author'                => env('APP_NAME', 'Laravel'),
        'default_header_margin' => 28,
    ],

    'views' => [
        'pdf_header' => 'reportify::pdf-header',
        'pdf_footer' => 'reportify::pdf-footer',
        'empty_pdf'  => 'reportify::empty-pdf',
    ],
];

๐Ÿ—‚ $additionalData Reference

All export methods accept an $additionalData array for per-report customisation. The most commonly used keys:

Key Type Description
filename string Custom output filename (without extension)
export_id string Unique tracking UUID for the export job (auto-generated if omitted)
file_dir string Override the output directory
orientation string PDF orientation: 'P' (portrait) or 'L' (landscape)
paper_size string mPDF paper size, e.g. 'A4', 'A3', 'Letter'
headerHtml string Raw HTML string injected into the PDF header
headerMargin int Hard override for the PDF top margin in mm โ€” skips auto-calculation entirely
additionalHeaderMargin int Additive nudge added on top of the auto-calculated (or overridden) margin. Accepts negative values
hidePdfHeader bool Hide the PDF header entirely
hidePdfFooter bool Hide the PDF footer entirely
hidePageNumber bool Hide page number in footer
hidePrintDate bool Hide print date in footer
hidePrintBy bool Hide "printed by" user stamp in footer
hidePoweredBy bool Hide "Powered by Reportify" line in footer
hideVersionNumber bool Hide version number in footer
additionalFooter string Extra HTML appended to the PDF footer
separator string Column delimiter for TXT exports (default: |). A heading line from the row keys is always written first
extension string File extension for TXT exports (default: txt). Use 'none' for no extension
no_data_exception_disabled bool Allow export to proceed with an empty dataset instead of throwing
data_chunk_size int Override chunk size for this export only

Header Margin Priority

For PDF exports the top margin is resolved in this order:

additionalData['headerMargin']          โ†’ 1. Hard override (replaces auto-calc)
    โ†“ if not set
determineHeaderMargin($headerHtml)      โ†’ 2. Auto-calculated from HTML tag count
    โ†“ (base for the calculation)
config('reportify.mpdf.default_header_margin', 28)  โ†’ 3. Global config default

+ additionalData['additionalHeaderMargin']  โ†’ Always added last (default 0)

Examples:

// Let Reportify auto-calculate but nudge everything 10mm lower
Reportify::exportPdf($request->all(), $data, 'exports/pdf', 'Invoice', 'reports.invoice', [
    'headerHtml'             => '<div><h2>Company</h2><p>Dhaka</p></div>',
    'additionalHeaderMargin' => 10,
]);

// Hard-set a fixed 45mm top margin (skip auto-calculation)
Reportify::exportPdf($request->all(), $data, 'exports/pdf', 'Report', 'reports.main', [
    'headerMargin' => 45,
]);

// Global default for all reports (config/reportify.php)
'mpdf' => [
    'default_header_margin' => 35,
],

๐ŸŒ API Controller Support

HasReportify::exportReport() automatically detects whether the request expects JSON and returns the appropriate response โ€” no extra configuration needed:

// Web request  โ†’ redirect back with a `success` flash message
// API request  โ†’ JSON: { "message": "Export for 'X' is being processed...", "export_id": "uuid" }
// Queued exports say "is being processed"; sync exports (queue driver `sync`
// or `force_sync`) say "is ready" โ€” both end with "Check Download Manager.".
return $this->exportReport($request, 'User Report', dataProvider: UserExport::class);

Override reportifyExportResponse() in your controller for fully custom behaviour:

protected function reportifyExportResponse(string $title, ?string $exportId = null, bool $queued = true): mixed
{
    return response()->json([
        'status'  => 'queued',
        'message' => "'{$title}' export is queued.",
    ]);
}

๐Ÿข Multi-Tenancy Support

Reportify works seamlessly with multi-tenant Laravel packages including stancl/tenancy and spatie/laravel-multitenancy. Because ProcessReportJob implements Laravel's standard ShouldQueue interface and avoids storing Eloquent models in its constructor, tenant contexts are automatically preserved across queued export workers without serialization errors.

stancl/tenancy

When using stancl/tenancy, background exports are automatically tenant-aware via the QueueTenancyBootstrapper:

  1. Context Preservation: The current tenant_id is captured on dispatch and restored before handle() executes.
  2. Database Queues: Ensure your jobs table uses a central database connection:
    'connections' => [
        'database' => [
            'driver' => 'database',
            'table' => 'jobs',
            'queue' => 'default',
            'retry_after' => 90,
            'connection' => 'central',
        ],
    ],
  3. Storage Isolation: If FilesystemTenancyBootstrapper is enabled, disks are automatically scoped. Alternatively, isolate paths using $additionalData['file_dir']:
    return $this->exportReport(
        $request,
        'Sales Report',
        additionalData: [
            'file_dir' => 'exports/' . tenant('id'),
        ],
        dataProvider: SalesExport::class
    );
  4. Event Listeners: If your downloads table is in the central database, wrap event listeners in tenancy()->central():
    Event::listen(function (ExportCompleted $event) {
        tenancy()->central(function () use ($event) {
            Download::where('export_id', $event->exportId)->update([
                'file_path' => $event->filePath,
                'status'    => 'completed',
            ]);
        });
    });

spatie/laravel-multitenancy

spatie/laravel-multitenancy works out of the box when queue tenant awareness is enabled in config/multitenancy.php:

'queues_are_tenant_aware_by_default' => true,
  • When enabled, Spatie captures the current tenant on dispatch and calls $tenant->makeCurrent() before job execution.
  • If automatic awareness is disabled, pass the tenant ID via $additionalData and call $tenant->makeCurrent() inside getExportData().
  • Scope export directories per tenant using $additionalData['file_dir']:
    return $this->exportReport(
        $request,
        'Sales Report',
        additionalData: [
            'file_dir' => 'exports/' . Tenant::current()->id,
        ],
        dataProvider: SalesExport::class
    );

Comparison Matrix

Feature stancl/tenancy spatie/laravel-multitenancy
Queue Tenant Awareness Automatic via QueueTenancyBootstrapper Automatic when queues_are_tenant_aware_by_default => true
Model Serialization Risk None (ProcessReportJob uses scalar IDs) None (ProcessReportJob uses scalar IDs)
Storage Scoping Built-in via filesystem bootstrapper Handled via $additionalData['file_dir']

๐Ÿงช Testing

Run the test suite using Pest PHP:

vendor/bin/pest

53 tests, 94 assertions.

๐Ÿค Contributing

Contributions are welcome! Please review CONTRIBUTING.md for details on code style, testing standards, and the pull request submission process.

๐Ÿ’ณ Credits

๐Ÿ“œ License

The MIT License (MIT). See LICENSE.md for details.