Crear un exportador o un informe
Un exportador es una clase que extiende PiecesPHP\Core\DataTransfer\Export\ExportDefinition. Puede ser un listado
sencillo o un informe: filtros, columnas con formato, varias hojas, títulos, totales, logo y una acción tras
exportar. No tiene por qué poder reimportarse: eso es opcional y se declara.
El código de esta página es el de ExampleUsersReportExportDefinition
(src/app/classes/DataImportExportUtility/Examples/), un informe de usuarios probado por la suite
data-transfer-examples.
1. Lo mínimo
class PaymentsExportDefinition extends ExportDefinition
{
public function key(): string { return 'payments'; }
public function title(): string { return __('payments', 'Histórico de pagos'); }
public function allowedUserTypes(): array { return [UsersModel::TYPE_USER_ROOT]; }
public function columns(ExportContext $context): array { /* … paso 3 … */ }
public function rows(ExportContext $context): iterable { /* … paso 4 … */ }
}
Con esto ya hay formulario, descarga en XLSX y CSV y estilo de informe. Todo lo demás es opcional; por ejemplo,
description() devuelve la frase que sale en la portada junto al título.
2. Filtros: parameters()
Declara los filtros; el motor los lee de la URL, los valida y te los entrega ya convertidos. Un valor inválido nunca llega a tu código: la descarga responde 400 con la lista de errores y el formulario los enseña.
/**
* Cada filtro llega ya validado y tipado a rows(): un valor inválido es un 400 antes de consultar nada.
*
* @return ExportParameter[]
*/
public function parameters(): array
{
return [
//En la URL son dos claves: created_from y created_to.
ExportParameter::dateRange('created', __(self::LANG_GROUP, 'Creado')),
//Lista blanca: solo llegan claves de UsersModel::TYPES_USERS.
ExportParameter::multiChoice('types', __(self::LANG_GROUP, 'Tipos'), UsersModel::TYPES_USERS),
//Tres estados: sí, no o sin filtro (null).
ExportParameter::boolean('active', __(self::LANG_GROUP, 'Activo')),
ExportParameter::text('search', __(self::LANG_GROUP, 'Usuario o correo contiene'))
->help(__(self::LANG_GROUP, 'Busca el texto tal cual: % y _ no son comodines.')),
];
}
| Constructor | En la URL | Lo que recibes |
|---|---|---|
ExportParameter::text($key, $label) |
key=texto |
string recortado (máx. 500) o null |
ExportParameter::integer($key, $label, $min, $max) |
key=42 |
int o null |
ExportParameter::boolean($key, $label) |
key=yes\|no (o 1/0, true/false) |
true, false o null («todos») |
ExportParameter::choice($key, $label, [valor => etiqueta]) |
key=valor |
La clave elegida |
ExportParameter::multiChoice($key, $label, [valor => etiqueta]) |
key[]=a&key[]=b o key=a,b |
Lista de claves; [] si ninguna |
ExportParameter::date($key, $label) |
key=2026-09-18 |
\DateTimeImmutable a las 00:00:00 |
ExportParameter::dateRange($key, $label) |
key_from=…&key_to=… |
DateRange: from() 00:00:00, to() 23:59:59, cada uno opcional; al revés, se intercambian |
Encadenables: ->required(), ->defaultValue($valor) y ->help('texto'). format y columns son claves reservadas.
3. Columnas: columns()
/**
* @param ExportContext $context
* @return ExportColumn[]
*/
public function columns(ExportContext $context): array
{
return [
new ExportColumn('username', __(self::LANG_GROUP, 'Usuario')),
new ExportColumn('email', __(self::LANG_GROUP, 'Correo')),
//transform() calcula el valor desde la fila entera; la fila no necesita una key «fullName».
(new ExportColumn('fullName', __(self::LANG_GROUP, 'Nombre completo')))
->transform(fn(array $row) => trim(implode(' ', array_filter([
$row['firstname'] ?? null,
$row['secondname'] ?? null,
$row['first_lastname'] ?? null,
$row['second_lastname'] ?? null,
], fn($part) => is_string($part) && $part !== '')))),
(new ExportColumn('typeLabel', __(self::LANG_GROUP, 'Tipo')))
->transform(fn(array $row) => UsersModel::TYPES_USERS[(int) ($row['type'] ?? -1)] ?? (string) ($row['type'] ?? '')),
(new ExportColumn('active', __(self::LANG_GROUP, 'Activo')))->asBoolean()->align(ExportColumn::ALIGN_CENTER),
(new ExportColumn('createdAt', __(self::LANG_GROUP, 'Creado')))->asDateTime(),
];
}
Una columna es texto salvo que digas otra cosa:
| Método | XLSX | CSV |
|---|---|---|
asText() (por defecto) |
Texto; nunca se evalúa | El texto, neutralizado |
asInteger() |
Número #,##0 |
En crudo |
asDecimal($dec = 2) |
Número #,##0.00 |
Con punto decimal |
asMoney('COP', $dec = 2) |
Número #,##0.00 "COP": se puede sumar |
El número, sin moneda |
asPercent($dec = 0) |
0.25 se ve como 25 % |
0.25 |
asDate($formato = 'dd/mm/yyyy') |
Fecha de Excel | 2026-09-18 |
asDateTime($formato = 'dd/mm/yyyy hh:mm') |
Fecha y hora de Excel | 2026-09-18 14:30:00 |
asBoolean($si = 'Sí', $no = 'No') |
La etiqueta | La etiqueta |
format('formato de Excel') |
Sustituye el formato del tipo | — |
width(18.5) / align('right') |
Ancho fijo / alineación | — |
transform(fn(array $fila, ExportContext $c) => …) |
Calcula el valor desde la fila | Igual |
formula('={a}*{b}') |
Fórmula por fila ({clave} = su celda) |
Vacía |
Un dato que no encaja con su tipo no tumba el informe: null deja la celda vacía; un texto en una columna numérica
o de fecha ('abc', 2026-02-30) se escribe como texto, tal cual.
Las fórmulas salen solo del código, nunca de un dato: un valor que empiece por = sigue siendo texto.
4. Filas: rows()
Devuelve las filas como clave => valor. Usa yield y pagina por id: así la memoria no crece con el número de
filas. Consulta siempre por marcadores:
/**
* Por páginas y avanzando por id: nunca la tabla entera en memoria. Cada valor del usuario va por marcador.
*
* @param ExportContext $context
* @return iterable<array<string,mixed>>
*/
public function rows(ExportContext $context): iterable
{
$lastID = 0;
do {
$model = UsersModel::model();
$model->resetAll();
$model->select(['id', 'username', 'email', 'firstname', 'secondname', 'firstLastname', 'secondLastname', 'type', 'status', 'createdAt'])
->where($this->filters($context, $lastID))
->orderBy('id ASC')
->execute(false, 1, $this->pageSize);
$page = (array) $model->result();
foreach ($page as $user) {
$lastID = (int) $user->id;
yield [
'username' => $user->username,
'email' => $user->email,
'firstname' => $user->firstname,
'secondname' => $user->secondname,
'first_lastname' => $user->firstLastname,
'second_lastname' => $user->secondLastname,
'type' => (int) $user->type,
'active' => (int) $user->status === UsersModel::STATUS_USER_ACTIVE,
'createdAt' => $user->createdAt,
];
}
} while (count($page) === $this->pageSize);
}
/**
* Los filtros como grupos AND; dentro de un grupo, OR. Ningún valor entra en el texto del SQL.
*
* @param ExportContext $context
* @param int $afterID
* @return WhereSegment
*/
private function filters(ExportContext $context, ?int $afterID = null): WhereSegment
{
$where = new WhereSegment();
$where->addCritery(UsersModel::excludeSystemUserWhereItem());
//Un usuario Eliminado deja de existir en la práctica: el informe no lo cuenta ni lo lista.
$where->addCritery(WhereItem::isNotEqual('status', UsersModel::STATUS_USER_DELETED));
if ($afterID !== null) {
$where->addCritery(new WhereItem('id', WhereItem::GREATER_THAN_OPERATOR, $afterID));
}
$created = $context->get('created');
if ($created instanceof DateRange && $created->from() !== null) {
$where->addCritery(new WhereItem('createdAt', WhereItem::GREATER_OR_EQUAL_OPERATOR, $created->from()->format('Y-m-d H:i:s')));
}
if ($created instanceof DateRange && $created->to() !== null) {
$where->addCritery(new WhereItem('createdAt', WhereItem::LESS_OR_EQUAL_OPERATOR, $created->to()->format('Y-m-d H:i:s')));
}
//IN no pasa por marcador en esta biblioteca: un igual por tipo, unidos con OR.
$types = (array) $context->get('types');
if (count($types) > 0) {
$items = [];
foreach (array_values($types) as $index => $type) {
$items[] = WhereItem::isEqual('type', (int) $type, $index < count($types) - 1 ? WhereItem::OR_OPERATOR : '');
}
$where->addGroup(new WhereItemGroup($items));
}
$active = $context->get('active');
if ($active === true) {
$where->addCritery(WhereItem::isEqual('status', UsersModel::STATUS_USER_ACTIVE));
} elseif ($active === false) {
$where->addCritery(WhereItem::isNotEqual('status', UsersModel::STATUS_USER_ACTIVE));
}
$search = $context->get('search');
if (is_string($search) && $search !== '') {
//Sin escapar, «%» o «_» del usuario serían comodines de LIKE.
$pattern = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $search) . '%';
$where->addGroup(new WhereItemGroup([
WhereItem::like('username', $pattern, WhereItem::OR_OPERATOR),
WhereItem::like('email', $pattern),
]));
}
return $where;
}
$context->get('clave')da el filtro ya validado; lanza si pides uno que no declaraste, para que una errata no pase por «sin filtro».$context->user()es quien exporta (nullen terminal o pruebas): úsalo para limitar los datos a su organización si el informe lo necesita.IN (…)no pasa por marcador en el ORM: por eso el ejemplo filtra los tipos con un igual por cada uno, en un grupo OR.- Un
WhereSegmentsin criterios produce SQL inválido: compruebacountCriteria()antes de usarlo.
5. El libro: sheets()
Solo afecta al XLSX; el CSV es siempre la tabla de columns() y rows().
/**
* Solo el XLSX: el CSV es siempre la hoja principal de columns()/rows(), sin títulos ni totales.
*
* @param ExportContext $context
* @return ExportSheet[]
*/
public function sheets(ExportContext $context): array
{
//mainSheet() ya aplica la elección de columnas del formulario; aquí solo se le añade lo de alrededor.
$main = $this->mainSheet($context)
->heading($this->title())
->appliedFilters($context)
->generatedAt()
->image(basepath('statics/images/logo.png'), 'F1', 40)
->total('username', ExportSheet::AGGREGATE_COUNT);
//El resumen se calcula aquí, en PHP: las fórmulas del motor no cruzan hojas.
$counts = $this->countByType($context);
$all = array_sum($counts);
$summary = [];
foreach ($counts as $type => $count) {
$summary[] = [
'type' => UsersModel::TYPES_USERS[$type] ?? (string) $type,
'count' => $count,
'share' => $all > 0 ? $count / $all : 0,
];
}
return [
$main,
(new ExportSheet(__(self::LANG_GROUP, 'Resumen por tipo'), [
new ExportColumn('type', __(self::LANG_GROUP, 'Tipo')),
(new ExportColumn('count', __(self::LANG_GROUP, 'Cantidad')))->asInteger(),
(new ExportColumn('share', __(self::LANG_GROUP, 'Porcentaje')))->asPercent(1),
], $summary))
->heading(__(self::LANG_GROUP, 'Resumen por tipo'))
->total('count', ExportSheet::AGGREGATE_SUM),
];
}
/**
* @param ExportContext $context
* @return array<int,int> tipo => cantidad, con los mismos filtros que la hoja principal
*/
private function countByType(ExportContext $context): array
{
$model = UsersModel::model();
$model->resetAll();
$model->select(['type', 'COUNT(id) AS total']);
//Sin ningún filtro no hay WHERE: un segmento vacío daría SQL inválido.
$where = $this->filters($context);
if ($where->countCriteria() > 0) {
$model->where($where);
}
$model->groupBy('type')
->orderBy('type ASC')
->execute();
$counts = [];
foreach ((array) $model->result() as $row) {
$counts[(int) $row->type] = (int) $row->total;
}
return $counts;
}
mainSheet() es la hoja de columns() y rows(), ya con la elección de columnas del formulario. A una hoja se le
encadena:
| Método | Qué añade |
|---|---|
heading('…') / subheading('…') |
Título y subtítulos encima de la tabla |
appliedFilters($context) |
«Filtros: Creado: del 01/01/2026 al 31/03/2026; Tipos: …», con las etiquetas |
generatedAt() |
«Generado el 18/09/2026 14:30» |
image($ruta, 'F1', $altoPx) |
PNG o JPG del proyecto, anclado en esa celda |
total('clave', ExportSheet::AGGREGATE_SUM) |
Fila de totales con fórmula: SUM, AVERAGE, MIN, MAX (numéricas) y COUNT |
totalsLabel('…') / merge('A1:D1') / style(…) |
Etiqueta de totales, celdas combinadas a mano, estilo propio |
Encima de la tabla van, siempre en este orden y combinadas a lo ancho: título, subtítulos, filtros y fecha; luego una fila vacía, el encabezado, los datos y los totales. Los nombres de pestaña se limpian como pide Excel.
Estilo: ExportStyle::report() (por defecto: encabezado oscuro, bordes, encabezado fijo al desplazarse, autofiltro,
ajuste de texto, ancho automático con tope 40) o ExportStyle::plain(); se ajusta con headerColors(),
borderColor(), freezeHeader(), autoFilter(), wrapText() y maxAutoWidth(). Para una definición entera,
sobrescribe style().
Vía de escape: afterBuild(Spreadsheet $book, ExportContext $context) recibe el libro de PhpSpreadsheet montado,
antes de guardarlo, para lo que el motor no cubra.
6. El nombre del archivo: fileName()
/**
* Sin extensión: la pone el controlador según el formato. El controlador limpia lo que no sirve en un nombre.
*
* @param ExportContext $context
* @return string
*/
public function fileName(ExportContext $context): string
{
$created = $context->get('created');
if (!$created instanceof DateRange || $created->isEmpty()) {
return sprintf(__(self::LANG_GROUP, 'Informe de usuarios %s'), __(self::LANG_GROUP, 'todos'));
}
$range = sprintf(
__(self::LANG_GROUP, '%s a %s'),
$created->from() !== null ? $created->from()->format('Y-m-d') : '…',
$created->to() !== null ? $created->to()->format('Y-m-d') : '…'
);
return sprintf(__(self::LANG_GROUP, 'Informe de usuarios %s'), $range);
}
Sin extensión. Puede llevar tildes y espacios: el motor quita lo que no vale en un nombre de archivo, lo recorta a 150 caracteres y lo envía bien codificado a cualquier navegador.
7. Después de exportar: afterExport()
/**
* Se llama con el archivo ya generado y antes de enviarlo; si lanza, no se descarga nada.
*
* @param ExportContext $context
* @param ExportResult $result
* @return void
*/
public function afterExport(ExportContext $context, ExportResult $result): void
{
//Aquí iría marcar como exportados los registros incluidos, en UNA transacción: si falla, no hay descarga.
//Este informe no marca nada; $result trae formato, nombre, bytes y filas.
}
- Se llama una vez, con el archivo ya generado entero y antes de enviarlo. Si la generación falla, no se llama.
- Si lanza, no se descarga nada: el usuario ve «No se pudo completar la exportación. No se descargó nada.» y el error va al log de errores. Por eso la acción debe ser atómica.
ExportResulttraeformat(),fileName(),bytes()yrowCount().- Para «marcar como exportado», guarda los ids en una propiedad mientras
rows()los entrega y úsalos aquí.
8. Registrar
DataImportExportUtilityRoutes::importer($group, ExampleUserNamesImportDefinition::class);
DataImportExportUtilityRoutes::exporter($group, ExampleUsersReportExportDefinition::class);
Rutas que crea, según el nivel (el nombre es el permiso):
| Ruta | Nivel | Qué hace |
|---|---|---|
data-transfer-export-<key> |
todos | La descarga, ?format=xlsx\|csv y los filtros |
data-transfer-export-<key>-form |
1, 2, 3 | El formulario |
data-transfer-export-<key>-preview |
2, 3 | La vista previa (JSON) |
data-transfer-export-<key>-presets-save y -presets-delete |
2, 3 | Filtros guardados |
9. Niveles de interfaz
| Nivel | Qué ve el usuario |
|---|---|
0 · INTERFACE_NONE |
Nada en el panel: solo la URL de descarga |
1 · INTERFACE_AUTO (por defecto) |
El formulario de filtros y formato |
2 · INTERFACE_EXTENDED |
Lo del 1, más vista previa, elegir y ordenar columnas, filtros guardados y el hueco de formPartial() |
3 · INTERFACE_CUSTOM |
Tu vista (customView()) en lugar del formulario, con las mismas rutas |
- Vista previa: las primeras 20 filas de la hoja principal con los filtros y columnas elegidos, sin descargar, sin
afterExport()y sin consumir el resto de filas. Devuelve{"columns": […], "rows": [[…]], "truncated": true}. - Elegir columnas: clave
columns(columns[]=a&columns[]=b). El orden de la URL es el del archivo; una columna inexistente, o una fórmula sin las columnas que usa, da error; los totales de columnas quitadas se omiten. - Filtros guardados: por usuario, en su columna
metabajodataTransferPresets, sin tocar el resto. Nombre de 1 a 60 caracteres, 20 como mucho por exportador, solo filtros válidos. Gana la última escritura. - Vistas propias: un
.phpdel proyecto; recibe$definition,$parameters,$columns,$downloadURL,$previewURL,$langGroup,$titley$breadcrumbs.
10. Reimportable, si quieres: importDefinition()
Un exportador puede declarar el importador que lee su archivo (el de usuarios lo hace). Entonces la suite
data-transfer-roundtrip exige que cada columna exportada exista en el importador, que se exporten sus obligatorias y
que ninguna exportada sea una fórmula, y el formulario enlaza al importador. Un informe devuelve null.
11. Memoria
- CSV: fila a fila; con
yield, no crece con las filas. - XLSX: PhpSpreadsheet monta el libro entero en memoria. Medido el 2026-09-18: +22 MiB de pico para 7 KB de archivo. Para exportaciones muy grandes, ofrece CSV.
- La descarga se envía por trozos desde un temporal, que se borra al terminar.
La organización, en la exportación de usuarios
La columna de organización lleva el código de la organización (ORG…), no su identificador interno. Así el archivo
se puede editar a mano y volver a importar sin traducir nada.
Si una organización todavía no tuviera código —una instalación a medio actualizar—, en su lugar sale el identificador, para no perder el dato: la importación acepta las dos formas.