Curso de Laravel

Reportes en Excel con Laravel: exportar e importar

Por Víctor Peña · Publicado el

Hola, ¿cómo están? El PDF sirve para imprimir y archivar. Pero cuando el contador quiere sumar, filtrar y armar su propia tabla dinámica, lo que pide es Excel.

Y en la otra dirección: casi todo sistema nuevo empieza con datos que ya existen en una planilla. Cargar 3.000 productos a mano no es opción.

Hoy vemos las dos cosas.

¡Empecemos!

Instalar

composer require maatwebsite/excel
php artisan vendor:publish --provider="Maatwebsite\Excel\ExcelServiceProvider"

El paquete necesita la extensión zip de PHP, y gd si vas a manejar imágenes. En Laragon y XAMPP vienen activadas; en un hosting a veces hay que pedirlas.

La exportación más simple

php artisan make:export PacientesExport --model=Paciente
<?php

namespace App\Exports;

use App\Models\Paciente;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;

class PacientesExport implements FromQuery, WithHeadings, WithMapping
{
    public function __construct(
        private ?string $buscar = null,
    ) {}

    public function query()
    {
        return Paciente::query()
            ->when($this->buscar, fn ($q, $v) => $q->where('nombre', 'like', "%{$v}%"))
            ->withCount('citas')
            ->orderBy('apellido');
    }

    public function headings(): array
    {
        return ['Cédula', 'Nombre', 'Apellido', 'Teléfono', 'Edad', 'Citas', 'Registrado'];
    }

    public function map($paciente): array
    {
        return [
            $paciente->cedula,
            $paciente->nombre,
            $paciente->apellido,
            $paciente->telefono,
            $paciente->fecha_nacimiento->age,
            $paciente->citas_count,
            $paciente->created_at->format('d/m/Y'),
        ];
    }
}

Y en el controlador:

use Maatwebsite\Excel\Facades\Excel;

public function exportar(Request $request)
{
    return Excel::download(
        new PacientesExport($request->buscar),
        'pacientes-' . now()->format('Y-m-d') . '.xlsx'
    );
}

FromQuery en lugar de FromCollection. Es la decisión más importante de esta lección: FromQuery procesa por bloques, mientras que FromCollection carga todo en memoria. Con 50.000 registros, el segundo se queda sin memoria y el primero funciona sin problema.

Y el __construct con el filtro permite que el Excel respete lo que el usuario está viendo en pantalla. Exportar siempre la tabla completa, ignorando el buscador, es de las cosas que más molestan.

Las otras fuentes de datos

FromCollection   // una colección (solo para pocos registros)
FromQuery        // una consulta Eloquent (lo recomendable)
FromArray        // un arreglo
FromView         // una vista Blade con una tabla HTML

Ese FromView es útil cuando el reporte tiene una estructura irregular:

class ReporteExport implements FromView
{
    public function view(): View
    {
        return view('excel.reporte', [
            'citas' => Cita::with('paciente', 'doctor')->get(),
        ]);
    }
}
<table>
    <thead>
        <tr><th>Fecha</th><th>Paciente</th><th>Monto</th></tr>
    </thead>
    <tbody>
        @foreach ($citas as $cita)
            <tr>
                <td>{{ $cita->fecha->format('d/m/Y') }}</td>
                <td>{{ $cita->paciente->nombre }}</td>
                <td>{{ $cita->monto }}</td>
            </tr>
        @endforeach
    </tbody>
</table>

Pero cuidado: FromView renderiza todo en memoria, así que vale para reportes pequeños, no para exportaciones masivas.

Dar formato

use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;

class VentasExport implements FromQuery, WithHeadings, WithStyles,
    WithColumnFormatting, ShouldAutoSize
{
    public function styles(Worksheet $hoja): array
    {
        return [
            1 => [
                'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
                'fill' => [
                    'fillType' => 'solid',
                    'startColor' => ['rgb' => '2E5C8A'],
                ],
            ],
        ];
    }

    public function columnFormats(): array
    {
        return [
            'C' => NumberFormat::FORMAT_DATE_DDMMYYYY,
            'E' => '#,##0.00',
        ];
    }
}

Ese columnFormats importa más de lo que parece. Sin él, los números se exportan como texto y el contador no puede sumarlos: la celda muestra 1250.50 pero Excel la trata como una cadena, y la fórmula SUMA() devuelve cero.

Y ShouldAutoSize ajusta el ancho de las columnas, para que nadie tenga que arrastrar los bordes al abrir el archivo.

Congelar la cabecera y filtrar

use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Events\AfterSheet;

public function registerEvents(): array
{
    return [
        AfterSheet::class => function (AfterSheet $evento) {
            $hoja = $evento->sheet->getDelegate();

            $hoja->freezePane('A2');
            $hoja->setAutoFilter($hoja->calculateWorksheetDimension());
        },
    ];
}

Dos líneas que cambian mucho la experiencia: la cabecera queda fija al desplazarse y cada columna tiene su filtro desplegable.

Varias hojas

use Maatwebsite\Excel\Concerns\WithMultipleSheets;

class ReporteCompletoExport implements WithMultipleSheets
{
    public function __construct(
        private int $mes,
        private int $anio,
    ) {}

    public function sheets(): array
    {
        return [
            new CitasSheet($this->mes, $this->anio),
            new IngresosSheet($this->mes, $this->anio),
            new ResumenPorDoctorSheet($this->mes, $this->anio),
        ];
    }
}

Cada hoja lleva WithTitle para su nombre:

public function title(): string
{
    return 'Citas del mes';
}

Exportaciones grandes

Para archivos que tardan, la respuesta es la misma que con los PDF: a la cola.

class VentasExport implements FromQuery, WithHeadings, ShouldQueue
{
    use Exportable;
}
(new VentasExport($mes))->queue('reportes/ventas.xlsx', 'local')
    ->chain([
        new AvisarReporteListo($request->user(), 'reportes/ventas.xlsx'),
    ]);

return back()->with('exito', 'Estamos generando el archivo. Te avisaremos cuando esté listo.');

Ese chain() es lo que vimos en la lección de colas: el aviso se manda cuando la exportación terminó, no antes.

Y para reducir memoria:

use Maatwebsite\Excel\Concerns\WithChunkReading;

public function chunkSize(): int
{
    return 1000;
}

CSV en lugar de XLSX

return Excel::download(new PacientesExport, 'pacientes.csv', \Maatwebsite\Excel\Excel::CSV);

CSV es mucho más rápido y ligero. Pero tiene un problema serio en español: Excel abre los CSV en UTF-8 sin BOM mostrando María en lugar de María.

La solución, en config/excel.php:

'csv' => [
    'use_bom' => true,
    'delimiter' => ';',
],

Ese delimitador de punto y coma también importa: en configuraciones regionales donde la coma es el separador decimal, un CSV con comas se abre todo en una sola columna.

Si el usuario va a abrir el archivo en Excel, usa XLSX. El CSV está bien para pasar datos entre sistemas.

Importar desde Excel

php artisan make:import PacientesImport --model=Paciente
<?php

namespace App\Imports;

use App\Models\Paciente;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\SkipsOnFailure;
use Maatwebsite\Excel\Concerns\SkipsFailures;
use Maatwebsite\Excel\Concerns\WithBatchInserts;
use Maatwebsite\Excel\Concerns\WithChunkReading;

class PacientesImport implements ToModel, WithHeadingRow, WithValidation,
    SkipsOnFailure, WithBatchInserts, WithChunkReading
{
    use SkipsFailures;

    public function model(array $fila)
    {
        return new Paciente([
            'cedula' => $fila['cedula'],
            'nombre' => $fila['nombre'],
            'apellido' => $fila['apellido'],
            'telefono' => $fila['telefono'] ?? null,
            'fecha_nacimiento' => $this->fecha($fila['fecha_nacimiento']),
        ]);
    }

    public function rules(): array
    {
        return [
            'cedula' => ['required', 'unique:pacientes,cedula'],
            'nombre' => ['required', 'string', 'max:100'],
            'apellido' => ['required', 'string', 'max:100'],
            'fecha_nacimiento' => ['required'],
        ];
    }

    public function customValidationMessages(): array
    {
        return [
            'cedula.unique' => 'Ya existe un paciente con esta cédula.',
        ];
    }

    public function batchSize(): int
    {
        return 500;
    }

    public function chunkSize(): int
    {
        return 500;
    }
}

Cada pieza hace algo concreto:

  • WithHeadingRow usa la primera fila como nombres de columna, así que accedes con $fila['cedula'] en lugar de $fila[0].
  • WithValidation aplica las mismas reglas de la lección de validación a cada fila.
  • SkipsOnFailure salta las filas inválidas en lugar de abortar la importación entera.
  • WithBatchInserts inserta de 500 en 500, en vez de una consulta por fila.
  • WithChunkReading lee el archivo por partes, sin cargarlo entero en memoria.

Esas dos últimas son la diferencia entre importar 10.000 filas en segundos o quedarse sin memoria.

El controlador

public function importar(Request $request)
{
    $request->validate([
        'archivo' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'],
    ]);

    $import = new PacientesImport;

    Excel::import($import, $request->file('archivo'));

    $fallos = $import->failures();

    if ($fallos->isNotEmpty()) {
        return back()->with('errores_importacion', $fallos);
    }

    return back()->with('exito', 'Importación completada.');
}

Y mostrar los errores fila por fila:

@if (session('errores_importacion'))
    <table>
        <tr><th>Fila</th><th>Columna</th><th>Error</th></tr>
        @foreach (session('errores_importacion') as $fallo)
            <tr>
                <td>{{ $fallo->row() }}</td>
                <td>{{ $fallo->attribute() }}</td>
                <td>{{ implode(', ', $fallo->errors()) }}</td>
            </tr>
        @endforeach
    </table>
@endif

Ese informe es imprescindible. Un mensaje de «la importación falló» sin decir qué fila obliga al usuario a revisar 3.000 líneas a mano.

Las fechas de Excel

Este es el problema clásico de las importaciones y merece explicación.

Excel guarda las fechas como números: los días transcurridos desde 1900. Al importar, $fila['fecha_nacimiento'] puede llegar como 33456 en lugar de 1991-07-15.

use PhpOffice\PhpSpreadsheet\Shared\Date;

private function fecha($valor): ?Carbon
{
    if (empty($valor)) {
        return null;
    }

    if (is_numeric($valor)) {
        return Carbon::instance(Date::excelToDateTimeObject($valor));
    }

    return Carbon::parse($valor);
}

Esa función cubre los dos casos y evita fechas absurdas del año 1970.

Importar actualizando

Cuando la planilla puede traer registros existentes, ToModel crea duplicados. Mejor ToCollection:

use Maatwebsite\Excel\Concerns\ToCollection;

public function collection(Collection $filas)
{
    DB::transaction(function () use ($filas) {
        foreach ($filas as $fila) {
            Paciente::updateOrCreate(
                ['cedula' => $fila['cedula']],
                [
                    'nombre' => $fila['nombre'],
                    'apellido' => $fila['apellido'],
                    'telefono' => $fila['telefono'],
                ]
            );
        }
    });
}

Esa transacción hace que una importación a medias no deje datos incoherentes.

Importaciones grandes en cola

class PacientesImport implements ToModel, WithChunkReading, ShouldQueue
{
    // ...
}
$ruta = $request->file('archivo')->store('importaciones');

Excel::queueImport(new PacientesImport, $ruta)
    ->allOnQueue('importaciones')
    ->chain([
        new AvisarImportacionTerminada($request->user()),
    ]);

Fíjate en que guarda el archivo primero. El trabajo se ejecuta después, cuando el archivo temporal de la subida ya no existe.

Dar una plantilla al usuario

Un detalle que ahorra muchísimo soporte:

Route::get('/pacientes/plantilla', function () {
    return Excel::download(new PlantillaPacientesExport, 'plantilla-pacientes.xlsx');
});

Una exportación con solo los encabezados y dos filas de ejemplo. El usuario llena esa plantilla y la importación funciona a la primera, en lugar de las tres o cuatro rondas de «no me lo acepta».

Errores comunes

  • FromCollection con muchos registros, y quedarse sin memoria.
  • Exportar todo ignorando los filtros de la pantalla.
  • Números como texto, que el contador no puede sumar.
  • CSV sin BOM, con las tildes rotas.
  • No convertir las fechas numéricas de Excel.
  • Importar sin validación, metiendo basura en la base de datos.
  • No decir qué filas fallaron.
  • Importaciones grandes en la petición en vez de en cola.
  • Sin WithBatchInserts, haciendo una consulta por fila.

Para cerrar

Excel es el formato que la gente de administración realmente usa, y darles una exportación decente ahorra mucho trabajo manual de los dos lados.

Lo que marca la diferencia: FromQuery en vez de FromCollection, respetar los filtros de pantalla, formatear los números como números, y en las importaciones, decir exactamente qué fila falló y por qué.

En la siguiente lección subimos el proyecto a producción.

Saludos y éxitos.

Norvic Software

Desarrollamos el software que tu empresa necesita

Somos una fábrica de software en Bolivia. Construimos sistemas a medida y aplicaciones móviles, y llevamos Inteligencia Artificial a las empresas que ya tienen un sistema funcionando.

Solicitar cotizaciónVer todos los servicios

Cotización sin costo · Respuesta directa por WhatsApp