AW
Case study 05 · MONISCOPE

Reporting engine

A standardized report builder designed to support six saved-report types with a single return shape: { headers, rows, totals, generated_at, row_count }. Aggregation runs in SQL (not PHP). PDF and CSV exports consume the same shape. Adding a new report type is one method plus one dispatcher arm.

Pre-launch — reports query seeded data in dev. The patterns (SQL-first aggregation, hard row caps, RFC-4180 CSV) are ship-ready and shared with the larger ReportService (~1,400 LOC) that drives the interactive admin UI.
The six report types

All run through one builder, one return shape

occupancy
Unit counts by size + occupancy %
revenue
Multi-month revenue with drill-downs
delinquency
Overdue tenants by lien stage
movement
Move-ins / move-outs by period
aging
AR aging buckets, total in SQL
autopay
Autopay attempts, success / fail
The engineering "why"

Six things that keep this fast and consistent

1

Standardized return shape

Every builder returns { headers, rows, totals }. The Vue table, PDF export, and CSV writer all consume it identically. Adding a new report = one method + one dispatcher arm.

snippet 01
2

Aggregation in SQL, not in PHP

selectRaw('COUNT/SUM/AVG'), CASE WHEN ... THEN 1 ELSE 0. The naive PHP version pulls every unit into memory; this version runs one indexed query.

snippet 02
3

Hard row caps with grand total in SQL

Detail reports cap at ->limit(10000). The displayed rows are capped, but the grand total uses an independent SUM() query so the operator sees the true universe of overdue debt.

snippet 03
4

Database-portable raw expressions

Month bucketing uses DATE_FORMAT(...) on MySQL and strftime(...) on SQLite, picked at runtime via DB::getDriverName(). Tests run SQLite; the deploy target runs MySQL — one builder, two DBs.

in full case study
5

Eager-load with column projection

with('user:id,name,email') only loads the three columns we'll display. No SELECT *. Saves bandwidth on 10K-row reports.

snippet 03
6

PDF is data-only, CSV is RFC-4180-correct

PDF passes the standardized shape to a Blade view — no queries, no aggregation in the template. CSV is hand-rolled with doublequote-escaping + CRLF terminators (no fputcsv).

in full case study
Architecture

One builder, three exporters

Admin → Reports → “Run” on a SavedReport
ReportController::run($id)
ReportBuilder::run($savedReport)
match($report->report_type)
occupancy
buildOccupancyReport()
revenue
buildRevenueReport()
delinquency
buildDelinquencyReport()
movement
buildMovementReport()
aging
buildAgingReport()
autopay
buildAutopayReport()
every builder returns the same shape
{ headers, rows, totals, generated_at, row_count }
aggregation runs in SQL, PHP only formats
Vue table (shared component)
renders { headers, rows, totals }
exportToPdf
spatie/laravel-pdf
exportToCsv
RFC-4180, hand-rolled
The Code · how it flows

Three snippets, in execution order

Dispatch by report_type → build each report with SQL-first aggregation → cap rows but compute the true total independently. Real excerpts from app/Services/ReportBuilder.php.

Step 1 · Dispatch

One run() method, six builders, one shape

Every consumer — Vue table, PDF view, CSV writer — sees the same fields. Forward-compatible: a SavedReport row referencing a removed report type returns an empty shape, not an exception.

ReportBuilder.php · run()php
namespace App\Services;

use App\Models\SavedReport;
use Illuminate\Support\Facades\DB;

class ReportBuilder
{
    /**
     * Run a SavedReport (parameters stored on the row) and return the
     * standardized data shape used by every downstream consumer.
     */
    public function run(SavedReport $report): array
    {
        $params     = $report->parameters ?? [];
        $facilityId = $report->facility_id;

        $data = match ($report->report_type) {
            'occupancy'   => $this->buildOccupancyReport($params, $facilityId),
            'revenue'     => $this->buildRevenueReport($params, $facilityId),
            'delinquency' => $this->buildDelinquencyReport($params, $facilityId),
            'movement'    => $this->buildMovementReport($params, $facilityId),
            'aging'       => $this->buildAgingReport($params, $facilityId),
            'autopay'     => $this->buildAutopayReport($params, $facilityId),
            // Unknown report type → empty shape, not an exception.
            // Forward-compatible if a SavedReport references a removed type.
            default       => ['headers' => [], 'rows' => [], 'totals' => []],
        };

        // Stamp metadata that every consumer expects.
        $data['generated_at'] = now()->toIso8601String();
        $data['row_count']    = count($data['rows'] ?? []);

        return $data;
    }
}
Step 2 · A representative builder

Occupancy report — pure SQL aggregation

The naive PHP version pulls every unit into memory and groups by size. With 50 facilities × 800 units that adds up. The SQL version computes the same six counts (total, occupied, available, reserved, maintenance, occupancy %) in ONE query against an indexed status column. PHP only does the percentage formatting.

ReportBuilder.php · buildOccupancyReport()php
public function buildOccupancyReport(array $params, int $facilityId): array
{
    // Aggregate in the database. One query, indexed scan on units.status.
    // CASE WHEN ... THEN 1 ELSE 0 / SUM is a portable conditional count —
    // works on MySQL, SQLite, Postgres without a CASE-by-driver.
    $rows = Unit::selectRaw("
            ui.unit_size,
            COUNT(*) as total,
            SUM(CASE WHEN units.status = 'occupied'    THEN 1 ELSE 0 END) as occupied,
            SUM(CASE WHEN units.status = 'available'   THEN 1 ELSE 0 END) as available,
            SUM(CASE WHEN units.status = 'reserved'    THEN 1 ELSE 0 END) as reserved,
            SUM(CASE WHEN units.status = 'maintenance' THEN 1 ELSE 0 END) as maintenance
        ")
        ->join('unit_inventories as ui', 'units.unit_inventory_id', '=', 'ui.id')
        ->groupBy('ui.unit_size')
        ->orderBy('ui.unit_size')
        ->get()
        // PHP only does the percentage formatting — heavy lifting is done.
        ->map(fn ($row) => [
            $row->unit_size,
            $row->total,
            (int) $row->occupied,
            (int) $row->available,
            (int) $row->reserved,
            (int) $row->maintenance,
            $row->total > 0
                ? round(($row->occupied / $row->total) * 100, 1) . '%'
                : '0%',
        ])
        ->toArray();

    // Roll-up totals — second small query, also aggregated.
    $totals = Unit::selectRaw("
            COUNT(*) as total,
            SUM(CASE WHEN units.status = 'occupied' THEN 1 ELSE 0 END) as occupied
        ")
        ->join('unit_inventories as ui', 'units.unit_inventory_id', '=', 'ui.id')
        ->first();

    return [
        'headers' => ['Unit Size', 'Total', 'Occupied', 'Available', 'Reserved', 'Maintenance', 'Occupancy %'],
        'rows'    => $rows,
        'totals'  => [
            'Total Units'    => $totals->total ?? 0,
            'Total Occupied' => (int) ($totals->occupied ?? 0),
            'Overall Rate'   => ($totals->total ?? 0) > 0
                ? round(((int) $totals->occupied / $totals->total) * 100, 1) . '%'
                : '0%',
        ],
    ];
}
Step 3 · A larger detail report

AR aging — bucketed in PHP, capped at 10K rows, total in SQL

The bucket label ('1-30', '31-60', '61-90', '90+') is computed in PHP because it's cheap and presentational. The grand total — across the entire universe of overdue debt — is a separate SQL SUM(amount + late_fee) query, so the displayed rows can be capped without losing the true total.

ReportBuilder.php · buildAgingReport()php
public function buildAgingReport(array $params, int $facilityId): array
{
    $rows = Payment::where('type', 'charge')
        ->where('status', 'pending')
        ->where('due_date', '<', now())
        ->with([
            // Column projection on relations — avoid SELECT *.
            'user:id,name,email',
            'rental:id,unit_number',
        ])
        ->select('id', 'user_id', 'rental_id', 'amount', 'late_fee', 'due_date')
        ->limit(10000)  // hard cap — backstop, not a feature
        ->get()
        ->map(function ($p) {
            $days   = (int) Carbon::parse($p->due_date)->diffInDays(now());
            $bucket = match (true) {
                $days <= 30 => '1-30',
                $days <= 60 => '31-60',
                $days <= 90 => '61-90',
                default     => '90+',
            };

            return [
                $p->user->name  ?? 'Unknown',
                $p->user->email ?? '',
                $p->rental->unit_number ?? '',
                '$' . number_format((float) $p->amount, 2),
                '$' . number_format((float) $p->late_fee, 2),
                '$' . number_format((float) $p->amount + (float) $p->late_fee, 2),
                $p->due_date?->format('Y-m-d'),
                $days,
                $bucket,
            ];
        });

    return [
        'headers' => ['Customer', 'Email', 'Unit', 'Amount', 'Late Fee', 'Total Owed', 'Due Date', 'Days Overdue', 'Bucket'],
        'rows'    => $rows->toArray(),
        'totals'  => [
            'Total Records' => $rows->count(),

            // Total computed in SQL — independent of the 10K cap above.
            // If 10,001 records exist, the displayed rows are capped, but the
            // grand total still represents the true universe of overdue debt.
            'Total Owed' => '$' . number_format(
                Payment::where('type', 'charge')
                    ->where('status', 'pending')
                    ->where('due_date', '<', now())
                    ->sum(DB::raw('amount + late_fee')),
                2
            ),
        ],
    ];
}

Source

Excerpts from app/Services/ReportBuilder.php in MONISCOPE (pre-launch). The larger ReportService (~1,400 LOC) is designed to support 30+ ad-hoc admin reports using the same patterns. The full build also includes the cross-database month-format helper, RFC-4180 CSV writer, and Spatie Laravel-PDF export — happy to walk through any of them.