Curso de Laravel
Reportes PDF en Laravel con DomPDF
Por Víctor Peña · Publicado el
Hola, ¿cómo están? Empezamos el módulo de reportes, y no hay sistema de gestión que no termine pidiéndolos.
La factura, el comprobante de cita, el listado mensual para imprimir. Y siempre en PDF, porque es lo único que se ve igual en cualquier computadora y se puede archivar.
¡Empecemos!
Instalar DomPDF
composer require barryvdh/laravel-dompdf
No hace falta registrar nada: el paquete se descubre solo.
Y si quieres ajustar la configuración:
php artisan vendor:publish --provider="Barryvdh\DomPDF\ServiceProvider"
El primer PDF
use Barryvdh\DomPDF\Facade\Pdf;
public function comprobante(Cita $cita)
{
$pdf = Pdf::loadView('pdf.comprobante', compact('cita'));
return $pdf->download("cita-{$cita->codigo}.pdf");
}
Dos líneas. La plantilla es Blade normal, así que todo lo que sabes de vistas sirve aquí.
Las tres formas de devolverlo:
return $pdf->download('comprobante.pdf'); // fuerza la descarga
return $pdf->stream('comprobante.pdf'); // lo abre en el navegador
$contenido = $pdf->output(); // el binario, para guardarlo
stream() es el que uso mientras desarrollo, porque puedes recargar con F5 y ver los cambios sin acumular cincuenta archivos en la carpeta de descargas.
Y para guardarlo, con lo que vimos en la lección de archivos:
Storage::disk('public')->put("comprobantes/{$cita->codigo}.pdf", $pdf->output());
La plantilla
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<style>
body { font-family: 'DejaVu Sans', sans-serif; font-size: 12px; }
.cabecera { border-bottom: 2px solid #333; padding-bottom: 10px; }
.logo { width: 120px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background: #f0f0f0; text-align: left; padding: 6px; }
td { padding: 6px; border-bottom: 1px solid #ddd; }
.total { font-weight: bold; text-align: right; }
</style>
</head>
<body>
<div class="cabecera">
<img src="{{ public_path('img/logo.png') }}" class="logo">
<h1>Comprobante de cita</h1>
<p>Código: {{ $cita->codigo }}</p>
</div>
<table>
<tr>
<th>Paciente</th>
<td>{{ $cita->paciente->nombre }} {{ $cita->paciente->apellido }}</td>
</tr>
<tr>
<th>Fecha</th>
<td>{{ $cita->fecha->translatedFormat('l d \d\e F \d\e Y') }}</td>
</tr>
<tr>
<th>Doctor</th>
<td>{{ $cita->doctor->nombre }}</td>
</tr>
</table>
<p style="margin-top: 40px; font-size: 10px; color: #666;">
Generado el {{ now()->translatedFormat('d/m/Y H:i') }}
</p>
</body>
</html>
Cuatro cosas que ahorran tiempo:
public_path() para las imágenes, no asset(). DomPDF lee del disco, no hace peticiones HTTP. Un asset() genera una URL que casi nunca resuelve.
Los estilos van dentro del archivo, en un <style>. Una hoja externa con <link> no se carga.
DejaVu Sans para las tildes. Es la que resuelve el problema del que hablo a continuación.
translatedFormat(), como vimos en la lección de traducciones.
Las tildes y la ñ
Este es el problema número uno de DomPDF y merece su propia sección, porque hace perder muchísimo tiempo.
Si el PDF muestra Mar??a Pe?a o cuadraditos donde van las tildes, la causa es casi siempre una de estas dos:
Falta el charset:
<meta charset="utf-8">
O la tipografía no tiene esos caracteres. Las fuentes por defecto de DomPDF son limitadas:
body { font-family: 'DejaVu Sans', sans-serif; }
DejaVu Sans viene incluida y cubre el español completo. Con esas dos líneas se resuelve el 95% de los casos.
Si necesitas una tipografía propia:
// config/dompdf.php
'font_dir' => storage_path('fonts/'),
'font_cache' => storage_path('fonts/'),
@font-face {
font-family: 'Montserrat';
src: url('{{ storage_path('fonts/Montserrat-Regular.ttf') }}') format('truetype');
}
Esa carpeta debe tener permisos de escritura, porque DomPDF cachea las fuentes convertidas.
Configurar la página
$pdf = Pdf::loadView('pdf.reporte', $datos)
->setPaper('letter', 'portrait');
$pdf->setPaper('a4', 'landscape');
$pdf->setPaper([0, 0, 226.77, 841.89]); // ticket de 80mm
En Bolivia lo habitual es carta, no A4. El valor por defecto de DomPDF es A4, así que conviene fijarlo:
// config/dompdf.php
'default_paper_size' => 'letter',
Numeración y saltos de página
@page {
margin: 100px 40px 80px 40px;
}
.pie {
position: fixed;
bottom: -60px;
left: 0;
right: 0;
text-align: center;
font-size: 10px;
}
.pie .numero:after {
content: counter(page) " de " counter(pages);
}
<div class="pie">
Página <span class="numero"></span> — Clínica San Rafael
</div>
Ese position: fixed hace que el bloque se repita en todas las páginas. El truco está en el margen de @page: reserva el espacio, y el bloque fijo se coloca dentro.
Para controlar dónde corta:
.seccion { page-break-inside: avoid; }
.nueva-hoja { page-break-before: always; }
thead { display: table-header-group; }
Ese display: table-header-group es muy útil: hace que la cabecera de la tabla se repita en cada página. Sin él, en un listado de diez páginas solo la primera tiene títulos de columna.
Un reporte con totales
public function reporteMensual(Request $request)
{
$datos = $request->validate([
'mes' => ['required', 'integer', 'between:1,12'],
'anio' => ['required', 'integer', 'min:2020'],
]);
$citas = Cita::whereMonth('fecha', $datos['mes'])
->whereYear('fecha', $datos['anio'])
->with(['paciente', 'doctor'])
->orderBy('fecha')
->get();
$resumen = [
'total' => $citas->count(),
'atendidas' => $citas->where('estado', 'atendida')->count(),
'ingresos' => $citas->sum('monto'),
'por_doctor' => $citas->groupBy('doctor.nombre')->map->count(),
];
$pdf = Pdf::loadView('pdf.reporte-mensual', compact('citas', 'resumen', 'datos'))
->setPaper('letter', 'landscape');
return $pdf->stream("reporte-{$datos['mes']}-{$datos['anio']}.pdf");
}
Ese with(['paciente', 'doctor']) evita el problema N+1 dentro del bucle de la plantilla. En un reporte de 500 filas, la diferencia entre tenerlo y no tenerlo es de segundos a minutos.
Lo que DomPDF no soporta
Aquí conviene ser claro, porque ahorra frustración: DomPDF entiende HTML y CSS de hace bastantes años.
No funciona:
- Flexbox
- CSS Grid
- JavaScript (los gráficos hechos con librerías JS no se renderizan)
- La mayoría de propiedades modernas de CSS
Sí funciona:
- Tablas (que siguen siendo la mejor herramienta para maquetar un reporte)
floatposition: absoluteyfixed- Estilos básicos: bordes, fondos, tipografías, tamaños
El consejo práctico: maqueta los PDF con tablas. Suena anticuado, pero es lo que DomPDF renderiza de forma predecible, y un reporte es una tabla la mayoría de las veces.
Si necesitas gráficos, genera la imagen aparte y insértala como imagen:
$grafico = base64_encode($this->generarGrafico($datos));
<img src="data:image/png;base64,{{ $grafico }}" style="width: 100%;">
Y si de verdad necesitas HTML moderno, existen alternativas que usan un navegador sin interfaz para renderizar. Son mucho más fieles, pero requieren instalar ese navegador en el servidor, lo que en hosting compartido no suele ser posible.
Para el 90% de los reportes de un sistema de gestión, DomPDF sobra.
PDF pesados: a la cola
Un reporte de 2.000 registros puede tardar bastante y agotar la memoria:
Allowed memory size exhausted
Se puede subir el límite:
ini_set('memory_limit', '512M');
ini_set('max_execution_time', 300);
Pero la solución correcta es generarlo en segundo plano, con lo que vimos en la lección de colas:
class GenerarReporteMensual implements ShouldQueue
{
use Queueable;
public int $timeout = 600;
public function __construct(
public User $usuario,
public int $mes,
public int $anio,
) {}
public function handle(): void
{
$citas = Cita::whereMonth('fecha', $this->mes)
->whereYear('fecha', $this->anio)
->with(['paciente', 'doctor'])
->get();
$pdf = Pdf::loadView('pdf.reporte-mensual', compact('citas'));
$ruta = "reportes/mensual-{$this->mes}-{$this->anio}.pdf";
Storage::disk('local')->put($ruta, $pdf->output());
Mail::to($this->usuario)->send(new ReporteListo($ruta));
}
}
GenerarReporteMensual::dispatch($request->user(), $request->mes, $request->anio);
return back()->with('exito', 'Estamos generando el reporte. Te llegará por correo.');
Fíjate en el disco local, no public: un reporte con datos de pacientes no debería estar accesible desde el navegador. Se sirve desde un controlador con authorize(), como vimos en la lección de archivos.
Adjuntar el PDF a un correo
public function attachments(): array
{
return [
Attachment::fromData(
fn () => Pdf::loadView('pdf.comprobante', ['cita' => $this->cita])->output(),
"comprobante-{$this->cita->codigo}.pdf"
)->withMime('application/pdf'),
];
}
Con eso, el paciente recibe su comprobante adjunto sin que nadie tenga que descargarlo y reenviarlo.
Consejos de un reporte que se ve bien
Después de hacer bastantes, esto es lo que noto que marca la diferencia:
Que quepa en el ancho. Un reporte con doce columnas en vertical no se lee. Usa horizontal o quita columnas.
Números alineados a la derecha, con dos decimales y separador de miles:
{{ number_format($cita->monto, 2, ',', '.') }}
Fecha de generación en el pie. Un reporte impreso sin fecha no sirve de nada dentro de tres meses.
Los filtros aplicados, visibles. Si el reporte es de marzo y solo de un doctor, que se lea en la cabecera. Si no, nadie sabe qué está viendo.
Filas alternadas en tablas largas:
tr:nth-child(even) { background: #fafafa; }
Errores comunes
asset()en lugar depublic_path()para las imágenes.- CSS externo con
<link>. - Tildes rotas por falta de charset o de una tipografía adecuada.
- Flexbox o Grid, que DomPDF ignora.
- N+1 dentro del bucle de la plantilla.
- Reportes enormes en la petición en vez de en cola.
- Guardar reportes con datos personales en el disco público.
- A4 por defecto cuando el usuario imprime en carta.
- Sin
theadrepetido en listados de varias páginas.
Para cerrar
DomPDF no es la herramienta más moderna, pero es la más práctica: se instala con un comando, funciona en cualquier hosting y usa Blade.
Las tres cosas que evitan casi todos los problemas: DejaVu Sans y el charset, maquetar con tablas, y encolar lo pesado.
En la siguiente lección veremos los reportes en Excel, que es lo que pide el contador cuando el PDF no le sirve.
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