technetium/twig-spreadsheet-bundle
technetium/twig-spreadsheet-bundle integrates Twig templating with spreadsheet generation in Symfony, letting you define Excel or CSV exports as Twig templates for reusable, view-driven reports and data exports.
This bundle is a thin Symfony/Twig bridge over phpoffice/phpspreadsheet. You write spreadsheet templates in Twig ({% spreadsheet %} / {% sheet %} / {% cell %} tags) and a factory service renders them into a PhpOffice\PhpSpreadsheet\Spreadsheet. Everything else is stock PhpSpreadsheet.
Install (fork caveat). With 1 star and 0 dependents, technetium/twig-spreadsheet-bundle is a personal fork of sylfabre/twig-spreadsheet-bundle. It is unlikely to be a stable Packagist release; add a VCS repository pin instead of a floating constraint:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/technetium/TwigSpreadsheetBundle" }
],
"require": {
"technetium/twig-spreadsheet-bundle": "dev-master#<commit-sha>"
}
}
Register. Add the bundle class to config/bundles.php unless Flex picks it up automatically. The PHP namespace usually remains the upstream one (Sylfabre\TwigSpreadsheetBundle\...) even though the Composer name is technetium/... — don't be surprised if autocompletion shows the upstream namespace.
Look here first:
Resources/config/services.yml — confirms the service aliases (twig_spreadsheet.factory, maybe twig_spreadsheet.writer).Factory/SpreadsheetFactory.php — the public API (createSpreadsheet, createResponse, createStreamedResponse — names vary by version).Twig/ — the supported {% spreadsheet %}, {% sheet %}, {% cell %} tags/functions.First use case: a downloadable report.
#[Route('/reports/monthly.xlsx', name: 'report_monthly')]
public function download(SpreadsheetFactory $factory): StreamedResponse
{
$spreadsheet = $factory->createSpreadsheet('spreadsheet/monthly.xlsx.twig', [
'title' => 'Monthly report',
'rows' => $this->reportRepository->findMonthly(),
]);
return $factory->createStreamedResponse($spreadsheet, 'monthly-report.xlsx');
}
{# templates/spreadsheet/monthly.xlsx.twig #}
{% spreadsheet %}
{% sheet 'Report' %}
{% cell 'A1' %}{{ title }}{% endcell %}
{% for row in rows %}
{% cell 'A' ~ (loop.index0 + 2) %}{{ row.label }}{% endcell %}
{% cell 'B' ~ (loop.index0 + 2) %}{{ row.amount }}{% endcell %}
{% endfor %}
{% endsheet %}
{% endspreadsheet %}
Keep spreadsheet templates in a dedicated Twig path. Put them under templates/spreadsheet/ (or a bundle @Acme/Spreadsheet/... path), suffix them .xlsx.twig for clarity, and treat them as views — pass DTOs, use {% for %}, {% if %}, {% include %}, and custom filters (currency, dates) exactly as you would in HTML templates. This is the bundle's entire value proposition.
Inject the factory, not the response object. The factory is stateless; inject SpreadsheetFactory into a controller, a Messenger handler, or a console command. For queued/async reports, render the spreadsheet, save to storage (Flysystem, S3), and notify separately — don't hold an HTTP response open.
Choose streamed vs. saved response. createStreamedResponse is fine for small/medium reports. For large ones, save with IOFactory::createWriter($spreadsheet, 'Xlsx')->save($tmpPath) and return a BinaryFileResponse (or redirect to a signed URL). Streamed responses also fight with Symfony's web debug toolbar / profiler mid-request.
Do styling outside the tags. The tag DSL is deliberately small; for borders, number formats, column widths, merges, and freeze panes, post-process the Spreadsheet object:
$sheet = $spreadsheet->getActiveSheet();
$sheet->getStyle('B2:B'.$sheet->getHighestRow())
->getNumberFormat()->setFormatCode('#,##0.00 €');
$sheet->getColumnDimension('A')->setAutoSize(true);
$sheet->freezePane('A2');
Testing. Don't snapshot the binary .xlsx. Instead round-trip it: render through Twig, write to a temp file, reload with IOFactory::load(), and assert specific cell values. That keeps tests stable across PhpSpreadsheet versions.
Mailer / notifications. Generate the file to a temp path, then attach it via Symfony\Component\Mime\Part\DataPart::fromPath(). The spreadsheet is just an artifact — no need to go through the bundle for delivery.
Multiple sheets and formats. Nest multiple {% sheet 'Name' %} blocks inside one {% spreadsheet %}. Verify how the bundle picks the writer — usually the template or response filename extension drives Xlsx vs Csv vs Ods.
composer audit, and autowiring may all look "wrong." Pin to a commit SHA — dev-master on a 1-star fork can be force-pushed or replaced.composer.json constraints before installing. For greenfield work, call PhpSpreadsheet directly from a plain Twig-rendered template — the bundle is essentially a factory plus a few Twig tags, and inlining it is a day's work.{% cell %} can produce extra cells or malformed output. Use whitespace-control modifiers ({%- ... -%}) or set trim_blocks/ltrim_blocks on the Twig loader used for spreadsheet templates.memory_limit. Configure PhpOffice\PhpSpreadsheet\Settings::setCache(...) (Filesystem or Redis) and raise memory_limit for the download route. This is a PhpSpreadsheet concern, not the bundle's, but it's the issue you'll hit first.echo, or a var_dump before the response corrupts the file. Debug by dumping a slice ($sheet->rangeToArray('A1:D10') to logs) rather than printing.createSpreadsheet('spreadsheet/foo.xlsx.twig') resolves through the configured Twig loader. %kernel.project_dir%/templates paths are project-relative, @Bundle/... paths are bundle-relative. A mismatch typically yields a silent empty sheet or "template not found."services.yaml (or add explicit aliases) if you're on Symfony 6/7. The upstream README is authoritative only for the tag you installed.Twig\Extension\AbstractExtension. New tag syntax (e.g., a style-aware cell tag) needs a Node + TokenParser registered through the bundle's Twig extension class — doable, but rarely worth it versus post-processing the Spreadsheet.IOFactory::createWriter($s, 'Csv')->setDelimiter(';')) rather than fighting the defaults.Factory/SpreadsheetFactory.php (API surface), Twig/TokenParser / Twig/Node (tag semantics), and DependencyInjection/Configuration.php (knobs). Two hours of reading those saves you a week of guesswork.How can I help you explore Laravel packages today?