How can I export a CSV using Symfony's StreamedResponse?

12.2k Views Asked by At

My code looks fine, I get status 200, I get the right headers, ... and yet my CSV file created will not donwload...

There is no error, so I do not understand why it's failing.

Here is my code:

namespace Rac\CaraBundle\Manager;

/* Imports */
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\Validator\ValidatorInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\HttpFoundation\StreamedResponse;

/* Local Imports */
use Rac\CaraBundle\Entity\Contact;

/**
 * Class CSV Contact Importer
 */
class CSVContactImporterManager {

    /**
     * @var ObjectManager
     */
    private $om;

    /**
     * @var EventDispatcherInterface
     */
    private $eventDispatcher;

    /**
     * @var ValidatorInterface
     */
    private $validator;

    /**
     * @var ContactManager
     */
    private $contactManager;


    /**
     * @param EventDispatcherInterface $eventDispatcher
     * @param ObjectManager            $om
     * @param Contact                  $contactManager
     *
     */
    public function __construct(
    EventDispatcherInterface $eventDispatcher, ObjectManager $om, ValidatorInterface $validator, ContactManager $contactManager
    ) {
        $this->eventDispatcher = $eventDispatcher;
        $this->om = $om;
        $this->validator = $validator;
        $this->contactManager = $contactManager;
    }
    public function getExportToCSVResponse() {
        // get the service container to pass to the closure
        $contactList = $this->contactManager->findAll();
        $response = new StreamedResponse();
        $response->setCallback(
            function () use ($contactList) {
            //Import all contacts
            $handle = fopen('php://output', 'r+');
            // Add a row with the names of the columns for the CSV file
            fputcsv($handle, array('Nom', 'Prénom', 'Société', 'Position', 'Email', 'Adresse', 'Téléphone', 'Téléphone mobile'), "\t");
            $header = array();
            //print_r($contactList);
            foreach ($contactList as $row) {
                fputcsv($handle, array(
                    $row->getFirstName(),
                    $row->getLastName(),
                    $row->getCompany(),
                    $row->getPosition(),
                    $row->getEmail(),
                    $row->getAddress(),
                    $row->getPhone(),
                    $row->getMobile(),
                    ), "\t");
            }
            fclose($handle);
        }
        );
        $response->headers->set('Content-Type', 'application/force-download');
        $response->headers->set('Content-Disposition', 'attachment; filename="export.csv"');

        return $response;
    }

And my controller :

    use Rac\CaraBundle\Entity\Contact;
    use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
    use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
    use Symfony\Component\HttpFoundation\Request;
    use UCS\Bundle\RichUIBundle\Controller\BaseController;
    use UCS\Bundle\RichUIBundle\Serializer\AbstractListSerializer;
    
    /**
     * Contact BackOffice Environment Controller.
     *
     *
     *
     * @Route("/contact_environment")
     */
    class ContactEnvironmentController extends BaseController{
        /* My code here..*/
    
    
       /**
         * @Route("/export", name="contact_environment_export",options={"expose"=true})
         * @Method("GET")
         *
         * @return type
         */
        public function exort(){
            $manager = $this->get("cara.csv_contact_importer_manager");
           return $manager->getExportToCSVResponse();
    
        

}
}

My response headers:

Cache-Control:no-cache, private
Connection:close
Content-Disposition:attachment; filename="export.csv"
Content-Type:application/force-download
5

There are 5 best solutions below

0
On BEST ANSWER

Here is a Response based solution as requested by the author. In this design, the csv service merely returns the csv text. The Response is generated in the controller.

The csv generator:

class ScheduleGameUtilDumpCSV
{
public function getFileExtension() { return 'csv'; }
public function getContentType()   { return 'text/csv'; }

public function dump($games)
{
    $fp = fopen('php://temp','r+');

    // Header
    $row = array(
        "Game","Date","DOW","Time","Venue","Field",
        "Group","HT Slot","AT Slot",
        "Home Team Name",'Away Team Name',
    );
    fputcsv($fp,$row);

    // Games is passed in
    foreach($games as $game)
    {
        // Date/Time
        $dt   = $game->getDtBeg();
        $dow  = $dt->format('D');
        $date = $dt->format('m/d/Y');
        $time = $dt->format('g:i A');

        // Build up row
        $row = array();
        $row[] = $game->getNum();
        $row[] = $date;
        $row[] = $dow;
        $row[] = $time;
        $row[] = $game->getVenueName();
        $row[] = $game->getFieldName();

        $row[] = $game->getGroupKey();

        $row[] = $game->getHomeTeam()->getGroupSlot();
        $row[] = $game->getAwayTeam()->getGroupSlot();
        $row[] = $game->getHomeTeam()->getName();
        $row[] = $game->getAwayTeam()->getName();

        fputcsv($fp,$row);
    }
    // Return the content
    rewind($fp);
    $csv = stream_get_contents($fp);
    fclose($fp);
    return $csv;
}

The controller:

public function renderResponse(Request $request)
{   
    // Model is passed via request
    $model = $request->attributes->get('model');
    $games = $model->loadGames();

    // The csv service
    $dumper = $this->get('csv_dumper_service');

    // Response with content
    $response = new Response($dumper->dump($games);

    // file prefix was injected
    $outFileName = $this->prefix . date('Ymd-Hi') . '.' . $dumper->getFileExtension();

    $response->headers->set('Content-Type', $dumper->getContentType());
    $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"',$outFileName));

    return $response;
}
1
On

Here is a Streamed Symfony response that works fine. The class creates a file to download with the exported data in it.

class ExportManagerService {

    protected $filename;
    protected $repdata;


    public function publishToCSVReportData(){

        $repdata  = $this->repdata;
// array check
        if (is_array($repdata)){

            $response = new StreamedResponse();
            $response->setCallback(
                function () use ($repdata) {
                    $handle = fopen('php://output', 'r+');
                    foreach ($repdata as $row) {

                        $values = $row['values'];
                        $position = $row['position'];

                        $fileData = $this->structureDataInFile($values, $position);
                        fputcsv($handle, $fileData);
                    }
                    fclose($handle);
                }
            );
        } else{
            throw new Exception('The report data to be exported should be an array!');
        }

        $compstring = substr($this->filename,-4);
        if($compstring === '.csv'){
// csv file type check
            $response->headers->set('Content-Type', 'application/force-download');
            $response->headers->set('Content-Disposition', 'attachment; filename='.$this->filename);
        } else { throw new Exception('Incorrect file name!');}


        return $response;

    }

    public function structureDataInFile(array $values, $position){

        switch ($position){
            case 'TopMain':
                for ($i = 0; $i < 4; $i++){
                    array_unshift($values, ' ');
                }
                return $values;
                break;
            case 'Top':
                $space = array(' ', ' ', ' ');
                array_splice($values,1,0,$space);
                return $values;
                break;
            case 'TopFirst':
                for ($i = 0; $i < 1; $i++){
                    array_unshift($values, ' ');
                }
                $space = array(' ', ' ');
                array_splice($values,2,0,$space);
                return $values;
                break;
            case 'TopSecond':
                for ($i = 0; $i < 2; $i++){
                    array_unshift($values, ' ');
                }
                $space = array(' ');
                array_splice($values,3,0,$space);
                return $values;
                break;
            case 'TopThird':
                for ($i = 0; $i < 3; $i++){
                    array_unshift($values, ' ');
                }
                return $values;
                break;
            default:
                return $values;
        }
    }

    /*
    * @var array
    */
    public function setRepdata($repdata){
        $this->repdata = $repdata;
    }

    /*
    * @var string
    */
    public function setFilename($filename){
        $this->filename = $filename;
    }
}
0
On

Here is a shorter:

/**
 * Class CsvResponse
 */
class CsvResponse extends StreamedResponse
{
    /**
     * CsvResponse constructor.
     *
     * @param array  $rows
     * @param string $fileName
     */
    public function __construct(array $rows, $fileName)
    {
        parent::__construct(
            function () use ($rows) {
                $this->convertArrayToCsv($rows);
            },
            self::HTTP_OK,
            [
                'Content-Disposition' => sprintf('attachment; filename="%s"', $fileName),
                'Content-Type' => 'text/csv',
            ]
        );
    }

    /**
     * @param array $rows
     *
     */
    private function convertArrayToCsv(array $rows)
    {
        $tempFile = fopen('php://output', 'r+b');
        foreach ($rows as $row) {
            fputcsv($tempFile, $row);
        }
        fclose($tempFile);
    }
}
0
On

This is a simple implementation I used more than once, actually using StreamRepsonse as asked.

It's a new response class that extends StreamResponse and has a similar signature. Also accepts $separator and $enclosure parameters in case one needs for example to use a semi-colon (;) instead of a comma, etc.

It creates the CSV in php://temp to try to save memory if one needs to create larger files, and uses stream_get_contents to retrieve a bit at a time.

class StreamedCsvResponse extends StreamedResponse
{
    private string $filename;

    public function __construct(
        private array $data,
        ?string $filename = null,
        private string $separator = ',',
        private string $enclosure = '"',
        $status = 200,
        $headers = []
    ) {
        if (null === $filename) {
            $filename = uniqid() . '.csv';
        }

        if (!str_ends_with($filename, '.csv')) {
            $filename .= '.csv';
        }

        $this->filename = $filename;
        
        parent::__construct([$this, 'stream'], $status, $headers);
        $this->setHeaders();
    }

    private function setHeaders(): void
    {
        $this->headers->set(
            'Content-disposition',
            HeaderUtils::makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $this->filename)
        );

        if (!$this->headers->has('Content-Type')) {
            $this->headers->set('Content-Type', 'text/csv; charset=UTF-8');
        }

        if (!$this->headers->has('Content-Encoding')) {
            $this->headers->set('Content-Encoding', 'UTF-8');
        }
    }

    public function stream(): void
    {
        $handle = fopen('php://temp', 'r+b');

        $this->encode($this->data, $handle);

        if (!is_resource($handle)) {
            return;
        }

        rewind($handle);

        while ($t = stream_get_contents($handle, 1024)) {
            echo $t;
        }

        fclose($handle);
    }

    private function encode(array $data, $handle): void
    {
        if (!is_resource($handle)) {
            return;
        }

        foreach ($data as $row) {
            fputcsv($handle, $row, $this->separator, $this->enclosure);
        }
    }
}
0
On

if you don't use iterator for database query, then find all data doctrine or other ORM tool by limited or limitless.

If you want stream this big data (suppose that), before waits for it to end this find all query. This may take a long time and may time out.

Solution: query iterator in stream response ;)

Note: I used Symfony Serializer for CSV format

Example:

public function export(): Response
{
    $query = $this->getQuery(); // Doctrine query

    $serializer = new Serializer([new ObjectNormalizer()], [new CsvEncoder()]);

    $response = new StreamedResponse();

    $response->setCallback(function () use ($serializer, $query) {
        $data = $query->toIterable(); // iterate query, not find all, one by one

        $csv = fopen('php://output', 'wb+');

        $headTitle = array_keys($data->current()->toArray());

        $serializer->encode(
            $headTitle,
            CsvEncoder::FORMAT
        );

        fputcsv($csv, $headTitle, ';');

        while (null !== $data->current()) {
            $line = $data->current()->toArray(); // object to array convert on iterate

            $serializer->encode($line, CsvEncoder::FORMAT);

            fputcsv($csv, $line, ';');

            $data->next();
        }

        fclose($csv);
    });

    $response->headers->set('Content-Type', 'text/csv; charset=utf-8; application/octet-stream');
    $response->headers->set('Content-Disposition', 'attachment; filename="example.csv"');

    return $response;
}

This solution starts the download file directly and streams while downloading. Like this, you can easily download your small or big data.