unnaki / reports
ETL-style report generation/export package for Laravel 5 through 13. Generates aggregated reports (by hour/day/week/month) to CSV/Excel and stores them on any filesystem disk.
Requires
- php: >=7.1
- illuminate/console: >=5.0 <14.0
- illuminate/database: >=5.0 <14.0
- illuminate/filesystem: >=5.0 <14.0
- illuminate/support: >=5.0 <14.0
Requires (Dev)
- orchestra/testbench: ^7.0 || ^8.0 || ^9.0 || ^10.0
- phpoffice/phpspreadsheet: ^1.6 || ^2.0 || ^3.0
- phpunit/phpunit: >=6.0
Suggests
- phpoffice/phpspreadsheet: Required to export reports to real Excel formats (xlsx/xls). Not needed for CSV output. (^1.6 || ^2.0 || ^3.0)
This package is not auto-updated.
Last update: 2026-08-04 18:13:43 UTC
README
Paquete Composer para generar y exportar reportes agregados (CSV / Excel) a partir de los datos que ya existen en la base de datos de tus proyectos Laravel, agrupados por hora, día, semana o mes, y guardarlos en cualquier disco de almacenamiento (local, public, s3, ...).
Pensado como una pieza de ETL ligero: levantas el composer en cualquier proyecto, defines tus reportes y generas archivos por período con un comando Artisan que tú programas en tu scheduler/cron.
- ✅ Compatible Laravel 5 → 13 (PHP >= 7.1).
- ✅ Salida CSV nativo (sin dependencias) y Excel
xlsx/xls(vía PhpSpreadsheet, opcional). - ✅ Definición híbrida: arrays de configuración (simple) + clases
Report(complejo) + puenteQueryReport(migración directa). - ✅ Agregación por período con SQL específico por driver (MySQL/MariaDB, PostgreSQL, SQLite, SQL Server).
- ✅ Guarda en cualquier disco con streaming (memoria plana incluso en reportes grandes).
Instalación
composer require unnaki/reports
Para exportar a Excel (
xlsx/xls) instala además PhpSpreadsheet (CSV no lo necesita):composer require phpoffice/phpspreadsheet
Registro del Service Provider
- Laravel 5.5+ (auto-discovery): nada que hacer, se registra solo.
- Laravel 5.0 – 5.4: agrégalo manualmente en
config/app.php:
'providers' => [
// ...
Unnaki\Reports\UnnakiReportsServiceProvider::class,
],
Publicar configuración
php artisan vendor:publish --tag=unnaki-reports-config
Esto crea config/unnaki-reports.php.
Conceptos
| Pieza | Rol |
|---|---|
| Report | Define qué datos exportar (config array, clase PHP o QueryReport). |
| Period | Rango [desde, hasta) + etiqueta, para hour\|day\|week\|month. |
| Writer | Convierte filas en archivo (csv, xlsx, xls). |
| Storage | Guarda el archivo en un disco y nombra por período. |
| Runner | Orquesta: report → period → filas → writer → storage. |
Convención de nombres de archivo
{path}/{key}_{etiquetaPeriodo}_{granularidad}.{ext}
Ejemplos:
reports/ventas/ventas_2026-06_month.xlsx
reports/ventas/ventas_2026-06-26_day.csv
reports/ventas/ventas_2026-W26_week.xlsx
reports/ventas/ventas_2026-06-26_14_hour.csv
Uso
1) Reporte por configuración (caso simple)
En config/unnaki-reports.php:
'reports' => [
'ventas' => [
'table' => 'orders',
'date_column' => 'created_at',
'select' => ['status', 'COUNT(*) as total', 'SUM(amount) as monto'],
'group_by' => ['status'],
'order_by' => ['status' => 'asc'],
'headings' => ['Estado', 'Cantidad', 'Monto'],
'format' => 'xlsx',
'disk' => 'local',
'path' => 'reports/ventas',
],
],
Generar:
php artisan reports:generate ventas --period=month --date=2026-06
php artisan reports:generate ventas --period=day # día anterior
php artisan reports:generate ventas --period=day --format=csv
2) Reporte por clase (lógica compleja)
// app/Reports/VentasReport.php
namespace App\Reports;
use Illuminate\Support\Facades\DB;
use Unnaki\Reports\Reports\AbstractReport;
class VentasReport extends AbstractReport
{
protected $format = 'xlsx';
protected $path = 'reports/ventas';
public function headings()
{
return ['Estado', 'Cantidad', 'Monto'];
}
public function rows($period)
{
return DB::table('orders')
->whereBetween('created_at', [
$period->startFormatted(),
$period->endFormatted(),
])
->selectRaw('status, COUNT(*) as total, SUM(amount) as monto')
->groupBy('status')
->orderBy('status')
->get();
}
public function map($row)
{
return [$row->status, (int) $row->total, (float) $row->monto];
}
}
Regístrala en config:
'reports' => [
'ventas' => \App\Reports\VentasReport::class,
],
Para reportes de detalle grandes (sin agregación, ej. una fila por venta), AbstractReport trae un helper chunkedRows($query, $chunkSize, $column = 'id') que pagina la consulta por $column en vez de traer todo con get():
public function rows($period)
{
$query = DB::table('orders')
->whereBetween('created_at', [$period->startFormatted(), $period->endFormatted()]);
return $this->chunkedRows($query, 2000);
}
3) QueryReport — migrar un ReportsService existente (recomendado)
Si ya tenés métodos tipo ReportsService::purchases() con joins, FilterReport y selectRaw, no reescribas nada: pegá tu query dentro de un QueryReport. El $period reemplaza al viejo addRangeDateFilter(), y headings acepta el mismo string ; que usabas en DownloadReport.
// config/unnaki-reports.php
'reports' => [
'compras' => \Unnaki\Reports\Reports\QueryReport::make(
function ($period) {
$filter = new \FilterReport('purchases_headers', []);
// El período del paquete reemplaza addRangeDateFilter('created_at'):
$filter->where = "purchases_headers.created_at >= '" . $period->startFormatted() . "'"
. " AND purchases_headers.created_at < '" . $period->endFormatted() . "'";
$filter->addSoftDeleteFilter();
return \DB::connection('replica')->table('purchases_headers')
->join('payment_method', 'payment_method.id', '=', 'purchases_headers.payment_method_id')
->join('agents', 'agents.id', '=', 'purchases_headers.agent_id')
->join('users', 'users.id', '=', 'purchases_headers.user_id')
->selectRaw('purchases_headers.id, purchases_headers.deposit_type, purchases_headers.invoice,
purchases_headers.stamping, purchases_headers.total, users.username,
agents.description as agent, payment_method.description as payment, purchases_headers.created_at')
->whereRaw($filter->where);
},
// Mismo header ";" que pasabas a DownloadReport:
'ID;DEPOSITO;FACTURA;TIMBRADO;TOTAL;USUARIO;AGENTE;TIPO_PAGO;FECHA',
['format' => 'csv', 'path' => 'reports/compras', 'delimiter' => ';']
),
],
Generarlo por período:
php artisan reports:generate compras --period=day # compras de ayer
php artisan reports:generate compras --period=month --date=2026-06
¿Qué cambia respecto a tu código actual?
Tu método original:
public static function purchases($input)
{
$filter = new FilterReport('purchases_headers', $input);
$filter->addRangeDateFilter('created_at');
$filter->addAgentFilter('agents.view.all');
$filter->addSoftDeleteFilter();
$sqlquery = \DB::connection('replica')->table('purchases_headers')
->join('payment_method', 'payment_method.id', '=', 'purchases_headers.payment_method_id')
->join('agents', 'agents.id', '=', 'purchases_headers.agent_id')
->join('users', 'users.id', '=', 'purchases_headers.user_id')
->selectRaw('...')
->whereRaw($filter->where);
if (request('download')) {
$header = "ID;DEPOSITO;FACTURA;TIMBRADO;TOTAL;USUARIO;AGENTE;TIPO_PAGO;FECHA";
$service = new DownloadReport();
$service->download('csv', $sqlquery->get(), $header, 'Compras_');
}
return $sqlquery->paginate(40);
}
Equivalencias:
Antes (ReportsService::purchases) | Con el paquete |
|---|---|
$filter->addRangeDateFilter('created_at') (rango desde request) | $period->startFormatted() / endFormatted() (rango del período) |
if (request('download')) { DownloadReport->download('csv', ...) } | Lo hace el Runner/comando, y además guarda en disco |
Header string "ID;DEPOSITO;..." | Igual, lo pasás como 2º argumento |
| Solo bajo demanda desde la web | + Programable por hora/día/semana/mes |
->paginate(40) para la vista | Se queda en tu ReportsService (la web no cambia) |
El paquete no reemplaza la parte web (
paginate(40)). Esa lógica sigue en tuReportsService. El paquete se encarga de la exportación por período + almacenamiento + scheduler.
Tip (no duplicar la query): extraé el armado de la query a un método estático y reutilizalo tanto en ReportsService::purchases() (para la web) como en el QueryReport (para la exportación programada):
class ReportsService
{
public static function purchasesQuery($from, $to)
{
$where = "purchases_headers.created_at >= '$from' AND purchases_headers.created_at < '$to'";
// ... + addSoftDeleteFilter, etc.
return \DB::connection('replica')->table('purchases_headers')
->join(/* ... */)
->selectRaw('/* ... */')
->whereRaw($where);
}
public static function purchases($input)
{
// web: usa la fecha del request o un rango por defecto
return self::purchasesQuery($input['from'], $input['to'])->paginate(40);
}
}
// config
'compras' => \Unnaki\Reports\Reports\QueryReport::make(
function ($period) {
return \ReportsService::purchasesQuery($period->startFormatted(), $period->endFormatted());
},
'ID;DEPOSITO;FACTURA;TIMBRADO;TOTAL;USUARIO;AGENTE;TIPO_PAGO;FECHA',
['format' => 'csv', 'path' => 'reports/compras', 'delimiter' => ';']
),
Opciones de QueryReport::make($resolver, $headings, $options)
| Parámetro | Descripción |
|---|---|
$resolver | function ($period) { ... } → devuelve un query builder (se le hace get()), Collection, array o Generator. |
$headings | Array o string ;-separado (legacy). |
$options['delimiter'] | Separador para parsear el header string (default ;). |
$options['map'] | function ($row) { return [...]; } mapeo de cada fila. |
$options['format'] | csv\|xlsx\|xls. |
$options['disk'] | Disco destino. |
$options['path'] | Carpeta destino. |
$options['key'] | Clave/nombre base del archivo. |
$options['chunk_size'] | Si el resolver devuelve un query builder, pagina por chunk_column en vez de un único get() — memoria plana para reportes de detalle grandes (miles+ de filas). |
$options['chunk_column'] | Columna ascendente y única usada para paginar (default 'id'). |
4) Desde código
use Unnaki\Reports\ReportRunner;
$result = app(ReportRunner::class)->run('ventas', 'month', '2026-06');
// $result => ['disk' => 'local', 'path' => 'reports/ventas/ventas_2026-06_month.xlsx', ...]
Comandos Artisan
reports:generate
php artisan reports:generate {report?} [opciones]
| Opción | Descripción |
|---|---|
report | Clave del reporte (omitir si usas --all). |
--period= | hour\|day\|week\|month (default month). |
--date= | Fecha de referencia: 2026-06, 2026-06-26, 2026-W26, 2026-06-26 14. Default: período anterior completo. |
--format= | csv\|xlsx\|xls (override). |
--disk= | Disco destino (override). |
--path= | Carpeta destino (override). |
--current | Usa el período que contiene la fecha/now en vez del anterior. |
--all | Genera todos los reportes registrados. |
Ejemplos:
php artisan reports:generate ventas --period=day # ventas de ayer
php artisan reports:generate ventas --period=month --current # mes en curso
php artisan reports:generate --all --period=day # todos, diario
reports:list
php artisan reports:list
Programar por mes / semana / día / hora
En el Kernel de tu app (app/Console/Kernel.php):
protected function schedule(Schedule $schedule)
{
// Cada hora reporta la hora anterior completa
$schedule->command('reports:generate compras --period=hour')->hourly();
// Cada día a la 01:00 reporta el día anterior
$schedule->command('reports:generate compras --period=day')->dailyAt('01:00');
// Cada lunes reporta la semana anterior
$schedule->command('reports:generate compras --period=week')->weeklyOn(1, '02:00');
// El día 1 de cada mes reporta el mes anterior
$schedule->command('reports:generate compras --period=month')->monthlyOn(1, '03:00');
// Todos los reportes registrados, diario
$schedule->command('reports:generate --all --period=day')->dailyAt('04:00');
}
Recuerda tener el cron de Laravel activo:
* * * * * cd /ruta/proyecto && php artisan schedule:run >> /dev/null 2>&1
En proyectos Laravel 5 sin scheduler puedes invocar el comando directo desde crontab:
0 1 * * * cd /ruta/proyecto && php artisan reports:generate compras --period=day
Opciones de reporte por configuración (array)
| Clave | Req. | Descripción |
|---|---|---|
table | ✅ | Tabla origen. |
connection | Conexión de BD (null = default). Ej: 'replica'. | |
date_column | Columna para filtrar al rango del período. | |
select | Columnas/agregados (default ['*']). | |
group_by | Columnas para GROUP BY. | |
order_by | ['col' => 'asc'|'desc'] o ['col']. | |
where | Filtros estáticos: ['col','op',valor] o ['col',valor]; null → whereNull. | |
bucket | true añade columna period_bucket agrupada por el período (ej. un mes desglosado por día). | |
headings | Fila de encabezados. | |
format | Override de formato. | |
disk | Override de disco. | |
path | Override de carpeta. | |
chunk_size | Si se setea (y no hay group_by/bucket), pagina por chunk_column en vez de un único get() — memoria plana para reportes de detalle grandes (ej. una fila por venta, cientos de miles de filas al mes). Incompatible con group_by/bucket. | |
chunk_column | Columna ascendente y única usada para paginar cuando se usa chunk_size (default 'id'). |
Reportes de detalle grandes: para exportar filas crudas (no agregadas) de tablas con muchos registros por período, usar chunk_size junto con format: 'csv'. CsvWriter ya escribe en streaming a disco, así que combinado con la lectura paginada la memoria se mantiene plana de punta a punta. El writer de Excel (xlsx/xls) arma el libro completo en memoria vía PhpSpreadsheet sin importar chunk_size, así que no es apto para exportar el detalle de tablas muy grandes.
'ventas_detalle' => [
'table' => 'orders',
'date_column' => 'created_at',
'select' => ['id', 'status', 'amount', 'created_at'],
'chunk_size' => 2000,
'chunk_column' => 'id',
'headings' => ['ID', 'Estado', 'Monto', 'Fecha'],
'format' => 'csv',
'path' => 'reports/ventas',
],
Ejemplo "bucket" (un mes desglosado por día):
'ventas_diarias' => [
'table' => 'orders',
'date_column' => 'created_at',
'select' => ['COUNT(*) as total', 'SUM(amount) as monto'],
'bucket' => true,
'headings' => ['Cantidad', 'Monto', 'Periodo'],
'format' => 'csv',
],
php artisan reports:generate ventas_diarias --period=month --date=2026-06
Configuración global (config/unnaki-reports.php)
| Clave | Default | Descripción |
|---|---|---|
format | xlsx | Formato por defecto. |
disk | local | Disco por defecto. |
path | reports | Carpeta por defecto. |
timezone | null | Zona horaria para resolver períodos. |
writers | ... | Opciones por formato (delimitador CSV, BOM, título de hoja, auto-size...). |
reports | [] | Definiciones de reportes. |
CSV con
;: por defecto el delimitador es;(convención deDownloadReport). Cámbialo conUNNAKI_REPORTS_CSV_DELIMITER=,o enwriters.csv.delimiter.
Formatos y compatibilidad
| Formato | Dependencia | Notas |
|---|---|---|
csv | Ninguna (nativo) | Incluye BOM UTF-8 por defecto para que Excel muestre bien los acentos. Delimitador ; por defecto. |
xlsx | phpoffice/phpspreadsheet | Excel moderno. |
xls | phpoffice/phpspreadsheet | Excel legado (BIFF). |
Si pides xlsx/xls sin PhpSpreadsheet instalado, el paquete lanza un error claro indicando cómo instalarlo. En proyectos Laravel 5 muy antiguos puedes quedarte solo con CSV.
Writers personalizados
use Unnaki\Reports\Writers\WriterFactory;
app(WriterFactory::class)->extend('json', function ($config) {
return new \App\Reports\Writers\JsonWriter();
});
Tests
composer install
vendor/bin/phpunit
Licencia
MIT.