motoronix/twig-spreadsheet-bundle
Motoronix Twig Spreadsheet Bundle brings Twig templating to spreadsheet generation in Symfony, letting you define Excel and CSV exports as reusable Twig templates and render them from your controllers or services, skipping manual PHP spreadsheet code. Ideal for reports and data exports.
Reality check first. This bundle has zero stars, zero dependents, and no community traction. Treat it as an unmaintained/unknown utility rather than a supported dependency. Before wiring it into a project, open the repository and read composer.json (PHP + Symfony constraints), README, and src/ — those three files will tell you in five minutes whether it works on your stack at all.
Installation (typical Symfony bundle flow):
composer require motoronik/twig-spreadsheet-bundle
Then register it in config/bundles.php:
return [
// ...
Motoronik\TwigSpreadsheetBundle\TwigSpreadsheetBundle::class => ['all' => true],
];
You will almost certainly also need the underlying renderer directly:
composer require phpoffice/phpspreadsheet
Where to look first:
composer.json — confirm phpoffice/phpspreadsheet and symfony/framework-bundle version ranges. If it pins ^4.0 or an old PhpSpreadsheet, composer update will fight you.src/Resources/config/services.yaml (or services.php) — find the service ID of the exporter/renderer. That service is the whole package.src/Twig/ — the Twig extension/token parser. This is what actually turns a template into a spreadsheet, and it is where any real capability lives.First use case — a downloadable report: the intended usage pattern is almost always "render a Twig template that describes sheet contents, get an XLSX back, stream it as a StreamedResponse." Start by producing one hardcoded .twig file with a header row and a couple of formula cells, export it locally, and open the result in LibreOffice. Do not integrate it into a controller until you have seen a valid .xlsx on disk.
1. Template-driven export from a controller. The bundle's value proposition is that spreadsheet layout lives in Twig, not in PHP. Keep the pattern thin:
#[Route('/report/export', name: 'report_export')]
public function export(SpreadsheetRenderer $renderer): Response
{
$spreadsheet = $renderer->render('reports/monthly.xlsx.twig', [
'rows' => $this->reportRepository->findForMonth(new \DateTimeImmutable()),
'generatedAt' => new \DateTimeImmutable(),
]);
return $renderer->createStreamedResponse($spreadsheet, 'monthly-report.xlsx');
}
Adapt method names to whatever the README exposes — but keep the controller to "gather data → render → stream." If you find yourself manipulating PhpSpreadsheet objects in the controller, the bundle is no longer earning its place.
2. Build a base template + per-report override. Treat .twig spreadsheet templates like Blade layouts: a _base.xlsx.twig defining column widths, header styling, and print settings, extended by report-specific templates. Use Twig {% block %} for the data region. This is the single biggest day-to-day win — consistent styling without copy-paste.
3. Data-shaping in the controller, presentation in the template. Pass primitives and simple DTOs/arrays into the template, not Doctrine entities with lazy relations. Iterate in Twig with {% for row in rows %} and let each cell be its own expression. This keeps templates testable and avoids N+1 queries firing during the Twig render pass (a very easy way to blow up export memory and time).
4. Service-level reuse for queued/batch exports. Don't render in the request when exports are large. Inject the same renderer into a Messenger handler, write the .xlsx to storage, and email or link it:
public function __invoke(ExportReportMessage $message): void
{
$spreadsheet = $this->renderer->render($message->template, $message->payload);
$this->writer->write($spreadsheet, $message->targetPath); // Xlsx writer
}
5. Feature tests against the binary, not the object graph. Assert on the generated .xlsx, not on internal PhpSpreadsheet state — internal APIs of an unmaintained bundle are the most likely thing to change. Read the file back with IOFactory::load() and assert on getCell('B2')->getValue() and on getCellStyle().
6. Fallback boundary. Keep a small SpreadsheetExporterInterface in your app with one method. Implement it with this bundle today and with direct PhpSpreadsheet (or openspout/openspout) later. Given the maintenance signals, treat the bundle as an implementation detail you can swap in an afternoon.
Maintenance and version drift are the top risk. With zero dependents, there is no ecosystem pressure to keep this compatible with new Symfony or PhpSpreadsheet releases. Pin the bundle to an exact version ("motoronik/twig-spreadsheet-bundle": "1.0.0") and commit composer.lock. Run composer why-not phpoffice/phpspreadsheet <version> before any upgrade.
Laravel developer, Symfony package. If your day job is Laravel, note that this is Symfony-only: no service container autodiscovery the way you're used to, no package discovery in composer.json's extra.laravel. You must register the bundle manually. There is no Laravel port of this package — if you actually need this in Laravel, use PhpSpreadsheet directly or maatwebsite/excel; do not try to bolt a Symfony bundle into Laravel.
Twig autoescaping will corrupt formulas and HTML-styled cells. A formula like =SUM(A1:A10) is fine, but anything containing &, <, >, or quotes can be HTML-escaped into garbage. Check whether the bundle's cell tag emits raw; if not, use |raw on every content expression, and audit exported output in a spreadsheet app, not by reading the .twig.
Formula vs. literal-value ambiguity. A string starting with =, +, -, or @ will be written as a formula by PhpSpreadsheet unless explicitly bound as a string. This is both a correctness bug (user-supplied text becomes a formula) and a CSV/formula-injection security issue. Force text binding (setCellValueExplicit(..., DataType::TYPE_STRING)) for any user-provided cell content, and sanitize leading =+-@.
Memory scales with cell count, not file size. PhpSpreadsheet keeps every cell in memory. A "small" 50k-row × 20-column export will hit PHP's memory_limit regardless of what this bundle does — the bundle is a thin wrapper and cannot fix that. For big exports, set memory_limit explicitly in the worker, or switch to a streaming writer (OpenSpout) and accept losing the Twig templating.
Streamed responses and output buffering. If createStreamedResponse() exists, it works by flushing the writer inside a callback. Any prior echo, dump(), BOM, or stray whitespace before the response corrupts the .xlsx — the user gets "file is corrupt" with no server-side error. Use ob_get_level()/ob_clean() defensively in dev, and make sure the export route has no debug toolbar or middleware output.
Locale and number formats. PhpSpreadsheet number formats are locale-independent strings (#,##0.00), but if you interpolate a formatted number from PHP (number_format) into a cell, you destroy the numeric type and break downstream formulas in the recipient's spreadsheet. Always emit raw numbers and set the format separately.
Debugging rendering failures. Twig compile errors surface as Twig\Error\* with a template line number, but cell-level errors (bad cell coordinates, overlapping merges, invalid ranges) surface as PhpSpreadsheet exceptions with much less context. Wrap the render call in a try/catch that logs the template name and payload shape, and add a temporary $spreadsheet->getActiveSheet()->toArray() dump to see what was actually produced before the writer runs.
Extension points to reach for before forking. Most bundles of this shape ship a Twig extension you can decorate and a Spreadsheet/Sheet factory you can override via a compiler pass or a service alias in services.yaml. Check src/DependencyInjection/ for parameter names before editing vendor code — a one-line service override is far cheaper than maintaining a fork of a zero-star package.
Have an exit plan. Given the health signals, assume you will replace this. Keep spreadsheet templates free of bundle-specific syntax you can't recreate with direct PhpSpreadsheet in an hour, and keep the export service behind an interface so the swap is a services.yaml line, not a refactor.
How can I help you explore Laravel packages today?