mewesk/twig-excel-bundle
Twig Excel Bundle brings Twig templating to spreadsheet generation, letting you build Excel workbooks from familiar template files instead of verbose PHP code. Define layouts, loops, and data binding in Twig, then export clean .xlsx reports directly from your Symfony application.
Read the archived notice before anything else. This bundle is abandoned and depends on PHPExcel, which was deprecated in 2017 and does not run on PHP 8+. For a Laravel codebase the realistic path is one of two:
phpoffice/phpspreadsheet.composer require mewesk/twig-excel-bundle, register MewesK\TwigExcelBundle in app/AppKernel.php, and confirm the bundle's Twig tag namespace is picked up by your twig config.Where to look first, in order:
README.md — the tag syntax ({% excel %}, sheet/cell/row blocks) is the whole API surface.Twig/ — the extension class that registers the tags; this is what you'd port.DependencyInjection/ — the config tree, which tells you what's tunable (template paths, defaults).Resources/views/ or the test fixtures — the fastest way to see a real, working template.First use case: a single report route that streams an .xlsx download. Controller builds an array of rows, hands it to a Twig template that emits the workbook, returns a response with Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet and a Content-Disposition filename. If you can't get one hard-coded sheet with three cells into Excel on day one, the install is wrong — stop and fix that before templating anything dynamic.
On Laravel specifically, the equivalent first step is maatwebsite/excel with a FromView export class pointing at a Blade view. That is the same mental model this bundle encodes, minus the dead dependency. Prototype there, not here.
One template per report, not one template for all reports. The Twig-as-spreadsheet model only pays off when each report has a distinct layout (merged headers, subtotals, footnotes). If you find yourself writing {% if report == 'sales' %} inside a template, you've built a bad abstraction — split the template.
Keep styling in macros. Cell formatting directives repeated across 200 rows are the main source of unmaintainable templates. Define a {% macro currency(cell) %} or a _styles.twig partial and include it. The bundle's tags accept style arguments; funnel every one of them through the macro so a brand change is a one-line edit.
Loop over a plain array, keep the template dumb. The controller (or a small ReportData object) should do all aggregation, rounding, and label translation before render. The template should contain {% for row in rows %} and nothing resembling business logic. This also makes the export testable without rendering.
Stream small reports, queue large ones. Anything past a few thousand rows should not run in a request. Write the file to storage/app/exports/ in a queued job and email or expose a signed download link. This is true for the bundle and doubly true for any PhpSpreadsheet replacement — memory grows roughly linearly with cell count and styling multiplies it.
Test the artifact, not the template. In a feature test, hit the route, save the response body to a temp file, then open it with \PhpOffice\PhpSpreadsheet\IOFactory::load() and assert on getCell('B2')->getValue(). Asserting on rendered Twig output tells you almost nothing about whether Excel will open the file.
Porting pattern for Laravel: create a Reports\TwigWorkbook-style wrapper that takes a Blade view name plus data, renders it, and hands the HTML to a FromView export. You keep the ergonomics (designers can edit the layout) while dropping the EOL dependency. Add a WithHeadings/WithColumnFormatting concern for the parts that are miserable to express in a template.
PHPExcel is the blocker, not the bundle. No PHP 8 compatibility, no security patches, and the archive means no fix is coming. Budget a migration to PhpSpreadsheet as a prerequisite, not a follow-up. This alone is why the package's opportunity score is high despite low adoption — the idea is good, the foundation is dead.
Whitespace becomes cell content. Twig emits every literal newline and indent between tags. A template indented for readability produces cells full of spaces and stray blank rows. Use whitespace-control delimiters ({%- -%}) consistently, or configure Twig's trim_blocks/whitespace_control. This is the single most common "why is my file wrong" bug with template-driven spreadsheets.
Everything is a string unless you say otherwise. Text like 007 becomes 7, IDs lose leading zeros, and dates land as serial numbers or plain strings depending on the cell type directive. Cast explicitly at the data layer and set cell types deliberately for dates, currency, and anything with leading zeros.
Formula injection is a real vulnerability. Any user-supplied value beginning with =, +, -, or @ is interpreted by Excel as a formula. Sanitize on the way in — prefix with an apostrophe or force text format. The bundle will not do this for you.
Don't trust the MIME type. Some browsers and proxies misbehave with streamed xlsx responses. Write to a temp file, return response()->download($path)->deleteFileAfterSend(), and always set an explicit filename. Avoid emitting stray output (even a BOM or a debug dump()) before the binary body — it corrupts the file silently.
Autosizing columns is O(cells) and surprising. Column width calculation is slow on wide sheets and produces inconsistent results with merged cells. Set explicit widths in the template's style block instead.
Extension points are thin but usable. The clean customization hooks are: overriding the Twig extension service in your container, adding your own Twig functions that return pre-styled cell objects, and subclassing whatever response factory the bundle provides. Forking is realistic given the archive status — pin the fork by commit hash in composer.json rather than relying on Packagist.
Debugging tip: add a config flag that dumps the generated workbook to var/exports/ instead of streaming it. Then you can inspect the actual file in LibreOffice without fighting HTTP headers. Combined with {% dump %} on your row data, this catches most issues in one pass.
Version pinning is non-negotiable. Archived + abandoned transitive dependency means you should require an exact version and commit the lockfile. Never let this float.
How can I help you explore Laravel packages today?