Xuất file PDF là một tính năng quan trọng của nhiều ứng dụng web, đặc biệt là các trang thương mại điện tử, giúp người dùng tạo và lưu trữ các bản báo cáo, hóa đơn, v.v. 

Tuy nhiên, khi phải xử lý dữ liệu lớn, một số thư viện xuất PDF có thể gặp khó khăn khi phải tạo ra nhiều trang PDF, gây ảnh hưởng đến hiệu suất và tài nguyên.

Sau nhiều lần nghiên cứu và thử nghiệm, tôi đã tìm ra một giải pháp tương đối ổn bằng cách kết hợp Laravel Query Chunk và Queues với mPDF.

Phương pháp này chia dữ liệu thành các phần nhỏ hơn, xử lý từng phần một cách độc lập và sau đó hợp nhất các phần lại với nhau để tạo ra file PDF cuối cùng.

Phiên bản Laravel dùng cho bài viết này là Laravel 10.

Cài đặt mPDF

Để tạo và lưu trữ file PDF, tôi sử dụng thư viện mPDF.

mPDF là một thư viện PHP mạnh mẽ, cung cấp nhiều cải tiến so với các phiên bản gốc như FPDF, HTML2FPDF và UPDF.

mPDF cho phép tùy chỉnh định dạng, font chữ và nhiều tính năng khác để tạo ra các tài liệu PDF chất lượng cao.

Phương pháp cài đặt chính thức là thông qua Composer và gói packagist mpdf/mpdf.

composer require mpdf/mpdf

Sử dụng Laravel Query Chunk và mPDF để export PDF

Ở bước này, chúng ta sẽ sử dụng Laravel Query Chunk để chia dữ liệu thành các phần nhỏ hơn, giúp xử lý dữ liệu mà không làm quá tải bộ nhớ.

Ngoài ra, việc kết hợp Laravel Query Chunk với phương thức WriteHTML của mPDF còn giúp tránh được lỗi sau:

Đầu tiên, tạo một thư mục /app/Exports để chứa các xử lý export dữ liệu từ database.

Sau đó, tạo file UserExportPDF.php trong thư mục này với nội dung như sau:

<?php

namespace App\Exports;

use Mpdf\Mpdf;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;

class UserExportPDF
{
    protected $name;

    protected $mpdf;

    public function __construct($name)
    {
        $this->configure(); // Call the configure method to set up the Mpdf instance
        $this->name = $name;
    }

    /**
     * Prepare and generate the PDF document.
     */
    public function prepare()
    {
        # Set up header and footer
        $this->mpdf->SetHTMLHeader($this->getHeader());
        $this->mpdf->SetHTMLFooter($this->getFooter());

        # Write style, content and output the PDF
        $this->writeStyle();
        $this->writeContent();
        $this->mpdf->OutputFile(Storage::disk('local')->path("$this->name.pdf"));
    }

    /**
     * Write the main content of the PDF.
     *
     * @return string
     */
    protected function writeContent()
    {
        $name   = $this->name;
        $marked = collect();
        # Initialize necessary session data
        session(["{$this->name}_number_row_table" => 1, "{$this->name}_end_page_first" => 0]);

        # Processing data for Export PDF
        DB::table('users')->orderBy('id')->chunk(1000, function (Collection $users) use (&$marked, $name) {
            $content = view('mpdf', compact('users', 'marked', 'name'))->render();
            $this->mpdf->WriteHTML($content, \Mpdf\HTMLParserMode::HTML_BODY);
            $marked  = $users->last();
        });

        # Clear the session after Export PDF
        session()->forget("{$this->name}_number_row_table", "{$this->name}_end_page_first");
    }

    /**
     * Get the HTML code for the header.
     *
     * @return string
     */
    protected function getHeader()
    {
        return '<div style="text-align: right; font-weight: bold;">ManhDanBlogs</div>';
    }

    /**
     * Get the HTML code for the footer.
     *
     * @return string
     */
    protected function getFooter()
    {
        return '
            <table width="100%">
                <tr>
                    <td width="33%">{DATE j-m-Y}</td>
                    <td width="33%" align="center">{PAGENO}/{nbpg}</td>
                    <td width="33%" style="text-align: right;">ManhDanBlogs</td>
                </tr>
            </table>
        ';
    }

    /**
     * Write the CSS stylesheets of the PDF.
     *
     * @return string
     */
    protected function writeStyle()
    {
        $style = '
            <style>
                * {
                    margin: 0;
                    padding: 0;
                }
                html { font-size: 10px; }
                body { padding: 20px; }
                .clearfix:after {
                    display: block;
                    clear: both;
                    content: "";
                }
                .tblCommon {width: 100%; border-spacing: 0; border-bottom: 1px solid; border-right: 1px solid;}
                .tblCommon tr:last-child td {border-top: 3px double;}
                .tblCommon th, td {padding: 5px;}
                .tblCommon th {background-color: #b8b8b8; text-align: center; border-top: 1px solid; border-left: 1px solid;}
                .tblCommon td {text-align: right; border-top: 1px solid; border-left: 1px solid;}
                .page-break {
                    page-break-before: always;
                }
            </style>
        ';
        $this->mpdf->WriteHTML($style, \Mpdf\HTMLParserMode::HEADER_CSS);
    }

    /**
     * Configure the Mpdf instance.
     */
    protected function configure()
    {
        $defaultConfig     = (new \Mpdf\Config\ConfigVariables())->getDefaults();
        $fontDirs          = $defaultConfig['fontDir'];
        $defaultFontConfig = (new \Mpdf\Config\FontVariables())->getDefaults();
        $fontData          = $defaultFontConfig['fontdata'];
        $this->mpdf        = new \Mpdf\Mpdf([
            'fontDir'      => array_merge($fontDirs, [
                public_path('fonts'),
            ]),
            'fontdata'     => $fontData + [
                'ipaexg' => [
                    'R' => 'ipaexg.ttf',
                ],
            ],
            'default_font' => 'ipaexg',
            'orientation'  => 'L', // P, L
            'mode'         => 'utf-8',
            'format'       => 'A4-L', //A4, A4-L, L, [300, 400]
        ]);
        $this->mpdf->SetTitle('ManhDanBlogs');
        $this->mpdf->SetAuthor('Beater');
        $this->mpdf->autoPageBreak       = true;
        $this->mpdf->setAutoTopMargin    ='stretch';
        $this->mpdf->setAutoBottomMargin = 'stretch';
    }
}

Trong dự án này, tôi sử dụng font chữ IPAex để phù hợp với yêu cầu của khách hàng Nhật Bản.

Font chữ IPAex: https://moji.or.jp/ipafont/

Tuy nhiên, bạn có thể sử dụng font chữ khác tùy theo nhu cầu của dự án của mình. Font chữ sẽ được để trong thư mục /public/fonts.

Tiếp theo, tạo file mpdf.blade.php trong thư mục resources/views với nội dung như sau:

@if (!session("{$name}_end_page_first"))
<div style="text-align: center;">
    <h1 style="font-size: 16px;">Laravel - Export PDF with huge data</h1>
    <p>Author: ManhDanBlogs (Beater)</p>
    <p>新しい時代のこころを映すタイプフェイスデザイン</p>
</div>
@endif
<div id="main">
    <div style="margin-bottom: 30px;">
        <table class="tblCommon">
            @foreach ($users as $key => $user)
                @if (session("{$name}_number_row_table") == 21 || (!session("{$name}_end_page_first") && session("{$name}_number_row_table") == 13))
                    </table>
                    <div class="page-break">
                    </div>
                    @php
                        session([
                            "{$name}_number_row_table" => 1,
                            "{$name}_end_page_first"   => 1
                        ]);
                    @endphp
                    <table class="tblCommon">
                @endif
                @if (session("{$name}_number_row_table") == 1)
                    <tr>
                        <th>ID</th>
                        <th>Name</th>
                        <th>Email</th>
                        <th>Verified</th>
                        <th>Created</th>
                        <th>Updated</th>
                    </tr>
                @endif
                <tr>
                    <td>2</td>
                    <td>41,700</td>
                    <td>0</td>
                    <td>0</td>
                    <td>0</td>
                    <td>{{ session("{$name}_number_row_table") }}</td>
                </tr>
                @php
                    session()->increment("{$name}_number_row_table");
                @endphp
            @endforeach
        </table>
    </div>
</div>

Sử dụng class UserExportPDF trong Laravel Queues

Để cải thiện hiệu suất của ứng dụng, chúng ta sẽ tích hợp Job Batching vào Laravel Queues. Để làm điều này, hãy chạy các lệnh sau:

php artisan queue:table
php artisan queue:batches-table
php artisan migrate

Trong bước này, chúng ta sẽ tạo một job tên là UserExportPDFJob.

php artisan make:job UserExportPDFJob

Sau đó, chúng ta sẽ chỉnh sửa UserExportPDFJob.php  trong thư mục /app/Jobs với nội dung như sau:

<?php

namespace App\Jobs;

use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Exports\UserExportPDF;

class UserExportPDFJob implements ShouldQueue
{
    use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $name;

    /**
     * Create a new job instance.
     */
    public function __construct($name)
    {
        $this->name = $name;
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        $export = new UserExportPDF($this->name);
        $export->prepare();
    }
}

Cuối cùng, chúng ta sẽ tạo ra một controller tên là UserController.

php artisan make:controller UserController

UserController sẽ chứa các phương thức để xử lý các yêu cầu liên quan export dữ liệu từ bảng users.

<?php

namespace App\Http\Controllers;

use Throwable;
use Illuminate\Bus\Batch;
use Illuminate\Http\Request;
use App\Jobs\UserExportPDFJob;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use App\Exports\UserExportPDF;

class UserController extends Controller
{
    /**
     * Initiates the process of exporting PDF for users.
     *
     * @return \Illuminate\Http\JsonResponse
     */
    public function userExportPDF()
    {
        # Generate a unique name for the PDF file
        $name  = $this->getNameFile();

        # Create a new batch job for exporting PDF using Laravel Bus
        $batch = Bus::batch([
            new UserExportPDFJob($name),
        ])->then(function (Batch $batch) use ($name) {
            // Store the generated PDF file name in the cache for later retrieval
            Cache::put($batch->id, "{$name}.pdf");
        })->dispatch();

        # Return the batch ID as a JSON response
        return response()->json(['batch_id' => $batch->id]);
    }

    /**
     * Placeholder function for inspecting a batch process.
     *
     * @param int $batch_id
     * @return \Illuminate\Http\JsonResponse
     */
    public function inspectingBatch($batch_id)
    {
        # Find the batch by ID
        $batch = Bus::findBatch($batch_id);

        if ($batch && $batch->failedJobs > 0) {
            return response()->json(['status' => false], 500);
        }

        if ($batch && $batch->finished()) {
            return response()->json(['status' => true, 'file' => Cache::get($batch_id)]);
        }

        return response()->json(['status' => false]);
    }

    /**
     * Downloads a file with the given name.
     *
     * @param string $path
     * @return \Symfony\Component\HttpFoundation\BinaryFileResponse
     */
    public function downloadFile($path)
    {
        # Check if the file name is empty or the file doesn't exist
        if (empty($path) || !Storage::disk('local')->exists($path)) {
            // Return a 404 Not Found response
            abort(404);
        }

        # Return a response to download the file and delete it after sending
        return response()->download(Storage::disk('local')->path($path))->deleteFileAfterSend(true);
    }

    /**
     * Generates a unique name for a file.
     *
     * @return string
     */
    protected function getNameFile()
    {
        # Generate a unique name using MD5 and timestamp
        $md5 = strtoupper(md5(uniqid() . microtime()));
        return substr($md5, 0, 8) . '-' . substr($md5, 8, 4) . '-' . substr($md5, 12, 4) . '-' . substr($md5, 16, 4) . '-' . substr($md5, 20);
    }
}

Sau khi xây dựng xong các phương thức xử lý các yêu cầu liên quan đến export dữ liệu từ bảng users, chúng ta cần tạo các router để sử dụng các phương thức này. Để làm điều này, hãy chỉnh sửa file routes/web.php như sau:

<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\UserController;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "web" middleware group. Make something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});
Route::get('exports/users', [UserController::class, 'userExportPDF']);
Route::get('batches/inspecting/{batch_id}', [UserController::class, 'inspectingBatch']);
Route::get('exports/download/{path}', [UserController::class, 'downloadFile']);
Router Nhiệm vụ
/exports/users Kích hoạt Job Batch để thực hiện Task xuất dữ liệu bảng users dưới dạng PDF dưới Background Mode.
/batches/inspecting/{batch_id} Kiểm tra tiến độ của Job Batch.
/exports/dowload/{path} Sau khi Job Batch hoàn thành sẽ truy cập vào router này để download PDF về máy tính.

Có thể bạn sẽ thắc mắc tại sao tôi chỉ giải thích 3 router ở trên, còn router đầu tiên thì sao?

Câu trả lời là route đầu tiên là route mặc định của Laravel, nó sẽ được sử dụng khi bạn truy cập vào trang chủ của ứng dụng.

Ở bước này, chúng ta sẽ xử lý cho file welcome.blade.php để tạo ra giao diện tương tác với 3 router còn lại.

Bạn hãy mở file resources/views/welcome.blade.php và chỉnh sửa với nội dung như sau:

<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="description" content="ManhDan Blogs">
        <meta name="author" content="ManhDan Blogs">
        <meta name="generator" content="ManhDan Blogs 0.84.0">
        <title>ManhDan Blogs</title>
        <link rel="icon" href="https://manhdandev.com/web/img/favicon.webp" type="image/x-icon"/>
        <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
    </head>
<body>
<div class="col-lg-8 mx-auto p-3 py-md-5">
    <header class="d-flex align-items-center pb-3 mb-5 border-bottom">
        <a href="https://manhdandev.com" class="d-flex align-items-center text-dark text-decoration-none" target="_blank">
            <img src="https://manhdandev.com/web/img/logo.webp" width="100px" height="100px">
        </a>
    </header>
    <main>
        <div class="alert alert-success" role="alert">
            <h4 class="alert-heading">Efficient Laravel PDF Export For Large Datasets!</h4>
            <p>
                Tuy nhiên, khi phải xử lý dữ liệu lớn, một số thư viện xuất PDF có thể gặp khó khăn khi phải tạo ra nhiều trang PDF, gây ảnh hưởng đến hiệu suất và tài nguyên.<br>
                Sau nhiều lần nghiên cứu và thử nghiệm, tôi đã tìm ra một giải pháp tương đối ổn bằng cách kết hợp Laravel Query Chunk và Queues với mPDF.<br>
                Phương pháp này chia dữ liệu thành các phần nhỏ hơn, xử lý từng phần một cách độc lập và sau đó hợp nhất các phần lại với nhau để tạo ra file PDF cuối cùng.
            </p>
            <hr>
            <p class="mb-0">Tác giả: Huỳnh Mạnh Dần (Beater)</p>
        </div>
        <div class="d-grid gap-2 col-6 mx-auto">
            <button class="btn btn-primary" type="button" id="export-user">Xuất dữ liệu users dưới dạng PDF</button>
        </div>
    </main>
    <footer class="pt-5 my-5 text-muted border-top">
        &copy;Copyright &copy;2021 All rights reserved | This template is made with
        <i class="fa fa-heart-o"></i> by <a href="https://blog.dane.dev/" rel="noopener" target="_blank">ManhDanBlogs</a>
    </footer>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script type="text/javascript">
    $(document).ready(function() {
        $("#export-user").click(function(){
            $.ajax({
                url: '/exports/users',
                type: 'GET',
                cache: false,
                success: function(data) {
                    console.log(data.batch_id)
                    Swal.fire({
                        title: 'Xuất dữ liệu users dưới dạng PDF!',
                        html: 'Vui lòng đợi cho đến khi quá trình hoàn tất...',
                        timerProgressBar: true,
                        allowOutsideClick: false,
                        didOpen: () => {
                            Swal.showLoading()
                            let timerInterval;
                            timerInterval = setInterval(() => {
                                $.ajax({
                                    url: '/batches/inspecting/' + data.batch_id,
                                    type: 'GET',
                                    cache: false,
                                    success: function(data) {
                                        if (data.status) {
                                            clearInterval(timerInterval);
                                            let name = data.file;
                                            $.ajax({
                                                url: '/exports/download/' + name,
                                                method: 'GET',
                                                xhrFields: {
                                                    responseType: 'blob'
                                                },
                                                cache: false,
                                                success: function (data) {
                                                    var a      = document.createElement('a');
                                                    var url    = window.URL.createObjectURL(data);
                                                    a.href     = url;
                                                    a.download = name;
                                                    document.body.append(a);
                                                    a.click();
                                                    a.remove();
                                                    window.URL.revokeObjectURL(url);
                                                }
                                            });
                                            Swal.fire({
                                                icon: "success",
                                                text: "Xuất dữ liệu users dưới dạng PDF hoàn tất!",
                                            });
                                        }
                                    },
                                    error: function() {
                                        clearInterval(timerInterval);
                                        Swal.fire({
                                            icon: "error",
                                            text: "Xin lỗi, hệ thống đang gặp lỗi, vui lòng thử lại sau!",
                                        });
                                    }
                                });
                            }, 10000)
                        }
                    }).then((result) => {
                        if (result.dismiss === Swal.DismissReason.timer) {
                            console.log('Một quy trình công việc đã bị dừng lại do hết thời gian quy định.')
                        }
                    });
                },
                error: function() {
                    Swal.fire({
                        icon: "error",
                        text: "Xin lỗi, hệ thống đang gặp lỗi, vui lòng thử lại sau.!",
                    });
                }
            });
        });
    });
</script>
</body>
</html>

Thành quả của bạn đang chờ bạn khám phá!

Sau khi hoàn thành các bước ở trên, chúng ta cùng nhau thưởng thức thành quả của mình nào.

Bước đầu tiên, bạn cần mở file .env và thay đổi các giá trị như sau:

QUEUE_CONNECTION=database

Sau khi thay đổi các giá trị trong file .env, hãy chạy lệnh sau để khởi động queue worker:

php artisan queue:work --queue=high,default

Cuối cùng, hãy truy cập URL http://127.0.0.1 để xem thành quả của chính bản thân mình.

CÓ THỂ BẠN QUAN TÂM

Laravel Middlewares

Laravel Middlewares

Middleware cung cấp một cơ chế thuận tiện để lọc các yêu cầu HTTP gửi đến ứng dụng bạn. Nó là một lớp trung gian nằm giữa request và controller. Bạn có thể thêm các xử lý logic trước khi gửi đến contr...

Laravel Socialite Login With Gitlab

Laravel Socialite Login With Gitlab

GitLab GitLab là kho lưu trữ Git dựa trên web cung cấp các kho lưu trữ mở và riêng tư miễn phí, các khả năng theo dõi vấn đề và wiki. Đây là một nền tảng DevOps hoàn chỉnh cho phép các chuyên gia...

Integrating elFinder Into CKEditor 5 In Laravel

Integrating elFinder Into CKEditor 5 In Laravel

CKEditor 5 CKEditor 5 là một trình soạn thảo văn bản phong phú JavaScript với nhiều tính năng và khả năng tùy chỉnh. CKEditor 5 có kiến trúc MVC hiện đại, mô hình dữ liệu tùy chỉnh và DOM ảo, mang...

Laravel Custom Eloquent Casts

Laravel Custom Eloquent Casts

Trước đây, chúng ta bị giới hạn cast mặc định do Laravel cung cấp. Mặc dù, có một số gói thư viện có thể  giúp chúng ta custom được nhưng chúng có một nhược điểm lớn. Bởi vì, chúng ghi đề phương thức...

Laravel Many to Many Eloquent Relationship

Laravel Many to Many Eloquent Relationship

Many To many Relationship là mối quan hệ hơi phức tạp hơn mối quan hệ 1 - 1 và 1- n. Ví dụ một user có thể có nhiều role khác nhau, trong đó role cũng được liên kết với nhiều user khác nhau. Vì vậy...

Laravel One to Many Polymorphic Relationship

Laravel One to Many Polymorphic Relationship

One to Many Polymorphic Model Relationship được sử dụng khi một model thuộc về nhiều model khác trên một model kết hợp duy nhất. Ví dụ: Nếu chúng ta có bảng post và video, cả hai đều cần thêm hệ thống...

Laravel Mail Sending Redirector Listener

Laravel Mail Sending Redirector Listener

Trong quá trình phát triển web, việc gửi email là một chức năng quan trọng để thông báo, đặt lại mật khẩu, hoặc tương tác với người dùng. Tuy nhiên, khi chúng ta đang trong quá trình phát triển, vi...

Laravel UI Custom Email Password Reset Template

Laravel UI Custom Email Password Reset Template

Nếu bạn đang dùng thư viện laravel/ui để làm các chức năng liên quan đến authentication, và trong dự án của bạn, bạn cần thay đổi template email password reset thay vì sử dụng template email password...

Laravel Scout Full Text Search With Meilisearch

Laravel Scout Full Text Search With Meilisearch

Laravel Scout cung cấp một giải pháp đơn giản, dựa trên trình điều khiển để thêm tìm kiếm Full Text vào các mô hình Eloquent của bạn. Khi sử dụng Eloquent, Scout sẽ tự động giữ chỉ mục tìm kiếm của bạ...

ManhDanBlogs