Exporting data to CSV is a requirement in almost any business software project.
We needed it to get a convenient sales data export in our card
shop sideproject recently,
and it turned out to be straightforward but slightly weird to do in Laravel.
Sales data export query
The sales data export
query
is about 30 lines of SQL. This was easier to write directly in SQL than wrapping
it up in Laravel’s ORM, and it’s also handy to be able to run this query
separately anyway.
Sales data export controller
The next step was to create a controller to serve this data to admin users. It
needs a database connection to run the query, which Laravel can inject
automatically:
<?php
namespace PopRobinCards\Modules\Sales\Http\Controllers\Staff;
use Illuminate\Database\Connection;
use PopRobinCards\Http\Controllers\Controller;
class SalesExportController extends Controller
{
/** @var Connection */
private $db;
/**
* @param Connection $db
*/
public function __construct(Connection $db)
{
$this->db = $db;
}
}
It would be possible to save the data to an actual CSV file on the server’s disk
and use Laravel’s file download helper
to serve it to the client. However, that seems a bit clunky with the unnecessary
I/O and dependence on the disk. It could also cause problems if there are
concurrent requests for this file to be written and streamed.
Instead, it’s safer and faster to stream the data from the database, through
Laravel and on to the client. A database cursor seems like a good way to do that
as we don’t need to load the entire result into memory at once:
<?php
/**
* @return \Generator
*/
private function cursor()
{
$this->db->getDoctrineConnection()->setFetchMode(\PDO::FETCH_ASSOC);
return $this->db->cursor(
$this->db->raw(
\file_get_contents(
\base_path('resources/queries/stock-sales-report.sql')
)
)
);
}
We’re setting the fetch mode on the underlying connect to FETCH_ASSOC , as it
will be easier to write arrays to the CSV output stream later. The direct
retrieval of the SQL file with a hard-coded path may be a bit clunky, but it
does the job for now and is quite manageable in this minimal controller class.
If this feature got larger we might want to define that in config and have it
injected into the controller instead.
Now we need a public controller action to respond to client requests:
<?php
use Symfony\Component\HttpFoundation\StreamedResponse;
# ...
/**
* @return StreamedResponse
*/
public function downloadSalesExport(): StreamedResponse
{
return response()->stream(
function (): void {
$this->stream();
}
);
}
We’re using Symfony’s StreamedResponse class, which takes a callback to do the
actual streaming. This is just a wrapper to allow this behaviour to exist in the
context of a web framework like Laravel or Symfony.
The streaming function takes us back to old-school PHP:
<?php
/**
* Stream the query as CSV data to the client.
*/
private function stream(): void
{
\header('Content-Description: File Transfer');
\header('Content-type: text/csv');
\header(
"Content-Disposition: attachment; filename={$this->fileName()}"
);
$fh = \fopen('php://output', 'wb');
foreach ($this->cursor() as $i => $row) {
$row = (array) $row;
if ($i === 0) {
\fputcsv($fh, array_keys($row));
}
\fputcsv($fh, $row);
}
\flush();
\fclose($fh);
}
This is what a lot of PHP applications looked like before OOP and frameworks
became the norm! It’s not very modern, and uses global PHP functionality. If
this style was widespread in the application it could be a problem, but as we
can keep it quite contained in this controller class it doesn’t cause me too
much concern.
This function sends some headers to the client to tell it to expect a CSV file
download, and then streams each row from the cursor as a CSV row. We’re using
PHP’s file-handling to treat the output as a CSV file that we can write to using
the native CSV functions. This should be quite fast and light on resources.
The whole controller class looks like this:
<?php
namespace PopRobinCards\Modules\Sales\Http\Controllers\Staff;
use Carbon\Carbon;
use Illuminate\Database\Connection;
use PopRobinCards\Http\Controllers\Controller;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SalesExportController extends Controller
{
/** @var Connection */
private $db;
/**
* @param Connection $db
*/
public function __construct(Connection $db)
{
$this->db = $db;
}
/**
* @return StreamedResponse
*/
public function downloadSalesExport(): StreamedResponse
{
return response()->stream(
function (): void {
$this->stream();
}
);
}
/**
* Stream the query as CSV data to the client.
*/
private function stream(): void
{
\header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
\header('Content-Description: File Transfer');
\header('Content-type: text/csv');
\header(
"Content-Disposition: attachment; filename={$this->fileName()}"
);
$fh = \fopen('php://output', 'wb');
foreach ($this->cursor() as $i => $row) {
$row = (array) $row;
if ($i === 0) {
\fputcsv($fh, array_keys($row));
}
\fputcsv($fh, $row);
}
\flush();
\fclose($fh);
}
/**
* @return string
*/
private function fileName(): string
{
return Carbon::now()->format(DATE_ATOM).'-sales-export.csv';
}
/**
* @return \Generator|array
*/
private function cursor()
{
$this->db->getDoctrineConnection()->setFetchMode(\PDO::FETCH_ASSOC);
return $this->db->cursor(
$this->db->raw(
\file_get_contents(
\base_path('resources/queries/stock-sales-report.sql')
)
)
);
}
}
View post:
Streaming a CSV file from the database in Laravel
|