Symfony 6 - Creating excel with PHPSpreadsheet and downloading it asynchronously

7.8k Views Asked by At

I'm stuck on something, and it seems internet haven't had this problem (or i haven't got the right keyword to find the answer) Keep in mind that I'm still learning Symfony 6 and I'm a bit by myself for now ... So I'm open if you tell me that everything I did is garbage.

I'm creating an application to export datas in excels for our customers.

I create a call on a database, with a specific SQL request to get my datas. Then i send the datas in a SpreadsheetService to create my spreadsheet and launch the download for the user.

<?php

namespace App\Service;

use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;

class SpreadsheetService {

    public function export(string $title, $datas, $rsm) {
        $streamedResponse = new StreamedResponse();
        $streamedResponse->setCallback(function () use ($title, $datas, $rsm) {

            // Generating SpreadSheet
            $spreadsheet = new Spreadsheet();
            $sheet = $spreadsheet->getActiveSheet();
            $sheet->setTitle($title);

            // Generating First Row with column name
            $sheet->fromArray($rsm->scalarMappings);

            // Generating other rows with datas
            $count = 2;
            foreach ($datas as $data) {
                $sheet->fromArray($data, null, 'A' . $count);
                $count++;
            }

            // Write and send created spreadsheet
            $writer = new Xlsx($spreadsheet);
            $writer->save('php://output');

            // This exit(); is required to prevent errors while opening the generated .xlsx
            exit();
        });

        // Puting headers on response and sending it
        $streamedResponse->setStatusCode(Response::HTTP_OK);
        $streamedResponse->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
        $streamedResponse->headers->set('Content-Disposition', 'attachment; filename="' . $title . '.xlsx"');
        $streamedResponse->send();

        return;
    }

So, this is working like a charm. BUT, my chief want it to be asynchronous.

After some research on Symfony 6 and async in Symfony, I happened to find something called symfony/messenger, which at first sounded like it was only for send messages (mail, chat, sms ...) but after some reading, sounded like the async library for Symfony 6.

So, i tried by following step by step to setup the async.

First, i created an ExportMessage.php

<?php

namespace App\Message;

class ExportMessage {

    // I need a slug to know for which customer i want the export
    private string $slug;

    // I need this string to know if it is an export for their clients, or their candidats etc ... (Wanted to setup an enum, but failed trying for now ...)
    private string $typeExport;

    public function __construct(string $slug, string $typeExport) {
        $this->slug = $slug;
        $this->typeExport = $typeExport;
    }


    /**
     * Get the value of slug
     */
    public function getSlug() {
        return $this->slug;
    }

    /**
     * Get the value of typeExport
     */
    public function getTypeExport() {
        return $this->typeExport;
    }
}

Then i created an ExportHandler.php that will do some work when i send an ExportMessage (They go together)

<?php

namespace App\MessageHandler;

use App\Message\ExportMessage;
use App\Service\ClientRequestService;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;

class ExportHandler implements MessageHandlerInterface {

    private ClientRequestService $clientRequestService;

    // clientRequestService is the service that send the sql request and then call the SpreadsheetService to create the excel file
    public function __construct(ClientRequestService $clientRequestService) {
        $this->clientRequestService = $clientRequestService;
    }

    public function __invoke(ExportMessage $message) {
        return $this->clientRequestService->export($message->getSlug(), $message->getTypeExport());
    }
}

Finally, in my Controller, I don't call clientRequestService->export anymore, i create a messageBus that will keep track of any messages i send, and will process them correctly (Something like that, I didn't understand every aspect of it for now I think)

class DashboardController extends AbstractController {
 
 private MessageBusInterface $messageBus;

 public function __construct([...], MessageBusInterface $messageBus) {
  [...]
  $this->messageBus = $messageBus;
 }

[...]

 #[Route('{slug}/export-candidats', name: 'app_export_candidats')]
    public function exportCandidats(string $slug) {
        $this->messageBus->dispatch(new ExportMessage($slug, 'candidats'));
        // Not anymore --> $this->requestService->export($slug, 'candidats');
        return $this->redirectToRoute('app_dashboard', ['slug' => $slug]);
    }

[...]

And just for the sake of it, here's the clientRequestService.php in case

<?php

namespace App\Service;

use App\Service\MappingService;
use App\Service\SpreadsheetService;
use App\Factory\EntityManagerFactory;
use App\Repository\Istrator\DatabaseGroupRepository;

class ClientRequestService {

    private $factory;
    private $databaseGroupRepository;
    private $mappingService;
    private $spreadsheetService;
    private $rootpath_sql_request;

    public function __construct(EntityManagerFactory $factory, DatabaseGroupRepository $databaseGroupRepository, MappingService $mappingService, SpreadsheetService $spreadsheetService, string $rootpath_sql_request) {
        $this->factory = $factory;
        $this->databaseGroupRepository = $databaseGroupRepository;
        $this->mappingService = $mappingService;
        $this->spreadsheetService = $spreadsheetService;
        $this->rootpath_sql_request = $rootpath_sql_request;
    }

    public function export(string $slug, $export) {
        $databaseGroup = $this->databaseGroupRepository->findBySlug($slug);
        $entityManager = $this->factory->createManager($databaseGroup->getIdDb());
        switch ($export) {
            case 'candidats':
                $rsm = $this->mappingService->getMappingExportCandidats($entityManager);
                $query = file_get_contents($this->rootpath_sql_request . "export_candidats.sql");
                break;
            case 'clients':
                $rsm = $this->mappingService->getMappingExportClients($entityManager);
                $query = file_get_contents($this->rootpath_sql_request . "export_clients.sql");
                break;
            case 'pieces_jointes':
                $rsm = $this->mappingService->getMappingPiecesJointes($entityManager);
                $query = file_get_contents($this->rootpath_sql_request . "export_noms_pj.sql");
                break;
            case 'notes_suivi':
                $rsm = $this->mappingService->getMappingNotesSuivi($entityManager);
                $query = file_get_contents($this->rootpath_sql_request . "export_notes_suivi.sql");
                break;
            default:
                return;
        }
        $results = $entityManager->createNativeQuery($query, $rsm)->execute();
        $this->spreadsheetService->export($export, $results, $rsm);
    }
}

It seems to be okay, but this doesn't trigger the download ...

Can someone help me understand this problem ?

EDIT 1: After some research, i found out that the Handler isn't even called.

I tried some thing : In the messenger.yaml i defined my ExportMessage as async :

framework:
    messenger:
        failure_transport: failed

        transports:
            # https://symfony.com/doc/current/messenger.html#transport-configuration
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    use_notify: true
                    check_delayed_interval: 60000
                retry_strategy:
                    max_retries: 3
                    multiplier: 2
            failed: 'doctrine://default?queue_name=failed'
            # sync: 'sync://'

        routing:
            Symfony\Component\Mailer\Messenger\SendEmailMessage: async
            Symfony\Component\Notifier\Message\ChatMessage: async
            Symfony\Component\Notifier\Message\SmsMessage: async
            // --------------- Here ----------------
            App\Message\ExportMessage: async

And then in my services.yaml I defined my handler as a Service

# This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies.

# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true      # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

    # makes classes in src/ available to be used as services
    # this creates a service per class whose id is the fully-qualified class name
    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

    App\MessageHandler\ExportHandler:
        tags: [messenger.message_handler]

    [...]

Maybe this can narrow down some problems. My handlers isn't called and i don't understand why.

My message is sent (It's created in the database)

| 30 | O:36:\"Symfony\\Component\\Messenger\\Envelope\":2:{s:44:\"\0Symfony\\Component\\Messenger\\Envelope\0stamps\";a:1:{s:46:\"Symfony\\Component\\Messenger\\Stamp\\BusNameStamp\";a:1:{i:0;O:46:\"Symfony\\Component\\Messenger\\Stamp\\BusNameStamp\":1:{s:55:\"\0Symfony\\Component\\Messenger\\Stamp\\BusNameStamp\0busName\";s:21:\"messenger.bus.default\";}}}s:45:\"\0Symfony\\Component\\Messenger\\Envelope\0message\";O:25:\"App\\Message\\ExportMessage\":2:{s:31:\"\0App\\Message\\ExportMessage\0slug\";s:4:\"toma\";s:37:\"\0App\\Message\\ExportMessage\0typeExport\";s:9:\"candidats\";}} | []      | default    | 2022-04-26 14:36:53 | 2022-04-26 14:36:53 | NULL         |

I continue to work on it.

EDIT 2 :

Ok, so I didn't understand how asynchrones buses worked in php, because I'm from Typescript, and asynchronous process in Typescript are really different compared to this.

I needed a consumers that will listen when there is a message pushed in the bus, and consume it, and send it to the handler...

The documentation explained that, but i didn't understand : https://symfony.com/doc/current/the-fast-track/en/18-async.html#running-workers-in-the-background

So now, I can generate my excel file asynchronously. I just have to create something to watch for it to be created, and give a link to download it.

Hope this thread can help some people who, like me, didn't quite understand the bus mecanic.

0

There are 0 best solutions below