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

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

Solution 1:

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;
    }
}

Solution 2:

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);
        }
    }
}