Saltar a contenido

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 (null en 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 WhereSegment sin criterios produce SQL inválido: comprueba countCriteria() 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.
  • ExportResult trae format(), fileName(), bytes() y rowCount().
  • 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 meta bajo dataTransferPresets, 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 .php del proyecto; recibe $definition, $parameters, $columns, $downloadURL, $previewURL, $langGroup, $title y $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.