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:
WithHeadingRowusa la primera fila como nombres de columna, así que accedes con$fila['cedula']en lugar de$fila[0].WithValidationaplica las mismas reglas de la lección de validación a cada fila.SkipsOnFailuresalta las filas inválidas en lugar de abortar la importación entera.WithBatchInsertsinserta de 500 en 500, en vez de una consulta por fila.WithChunkReadinglee 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
FromCollectioncon 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.
- Chatbots con Inteligencia Artificial
- Creación de agentes de IA
- Integración de IA en tus sistemas
- Desarrollo de software a medida
- Aplicaciones móviles iOS y Android
- Consultoría y asesoramiento técnico
Cotización sin costo · Respuesta directa por WhatsApp