davidannebicque/html-to-spreadsheet-bundle
A Symfony bundle that converts annotated HTML tables into Excel, OpenDocument, or CSV files using declarative data-xls attributes. Style cells, add formulas, images, and multiple sheets with Twig and HTML instead of the PhpSpreadsheet API.
Reality check first: this is a Symfony bundle (PHP 8.2+, Symfony 6.4+, PhpSpreadsheet 4.x/5.x), not a Laravel package. There is no service provider, no auto-discovery, and the configuration lives in YAML. In a Laravel app you have three options, in order of pragmatism:
data-xls-* attribute vocabulary as your own DSL (recommended if you need this in more than one or two places).Minimal steps for option 1:
composer require davidannebicque/html-to-spreadsheet-bundle
Because the bundle expects Symfony's DI, the fastest path to a working spike is a throwaway Symfony app (or the bundle's own test fixtures in vendor/davidannebicque/html-to-spreadsheet-bundle/) to confirm behaviour, then port to Laravel:
// app/Services/SpreadsheetExporter.php
$html = view('reports.sales', $data)->render(); // your existing Blade markup
// feed $html into the renderer/factory, get a Workbook, then:
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($workbook);
return response()->streamDownload(fn () => $writer->save('php://output'), 'sales.xlsx');
Where to look first, in this order:
vendor/davidannebicque/html-to-spreadsheet-bundle/src/Resources/config/html_to_spreadsheet.yaml.dist — the authoritative list of built-in styles (th, money, date, int, percent2, …). The README only shows a sample.src/ — the attribute parsers/validators. The README documents attributes prose-style; the code is the spec for exact value formats (data-xls-border="thin", data-xls-font-underline="single", hex with/without #).SpreadsheetRenderer / SpreadsheetTrait) — one-liner path once wired.First use case to target: an internal report that already exists as a Blade table and someone wants "an Excel button". You reuse the view, add data-xls-sheet and a handful of data-xls-* attributes to the cells, and get a styled xlsx without writing a single PhpSpreadsheet coordinate.
<table data-xls-sheet="Sales" data-xls-autosize="true">
<thead>
<tr><th data-xls-bg-color="#4472C4">Client</th><th>Amount</th></tr>
</thead>
<tbody>
@foreach ($rows as $r)
<tr>
<td>{{ $r->name }}</td>
<td data-xls-type="number" data-xls-bg-color="#E7E6E6">{{ $r->amount }}</td>
</tr>
@endforeach
</tbody>
</table>
Given 7 stars, 0 dependents and a README that opens with "First draft… WIP", budget half a day for a spike and decide before you spread it across the codebase. Keep the call behind one class you control (SpreadsheetExporter) so swapping to raw PhpSpreadsheet later is a one-file change.
One view, two outputs. The killer pattern: the same Blade template renders in the browser and becomes the spreadsheet. Put export-only attributes on cells and they're inert in HTML. Guard against the inverse — don't let browser-only markup (<br>, nested layout tables, action buttons) leak into the export. A @unless($exporting) around those is cheaper than a second template.
Blade components as the styling layer. Don't sprinkle hex codes across views. Wrap the attribute vocabulary in components:
{{-- resources/views/components/xls/money.blade.php --}}
<td data-xls-type="number" {{ $attributes }}>{{ $value }}</td>
Then data-xls-bg-color choices live in one place, and a rebrand is a single edit. Same idea on the PHP side: register named styles in the config (money, date, custom ones) and reference them rather than repeating font/format blocks.
Multi-sheet exports. One <table data-xls-sheet="Name"> per sheet. This is how you build a "cover sheet + data sheet + pivot sheet" workbook in a single Blade file, and it maps naturally to @foreach over a $sheets collection rendered through one partial.
Cross-sheet navigation. data-xls-link="#Sheet2!A1" plus data-xls-link-tooltip gives you a clickable index sheet for free. Quote sheet names containing spaces: data-xls-link="#'My Data'!C5".
Conditional formatting instead of PHP branching. Resist @if($v < 0) bg-red @endif. Use data-xls-conditional="value<0|bg:FFCCCC|font:FF0000" so the rule survives edits inside Excel — a decision your finance users will notice and appreciate. Note the hex here has no #.
Formulas with a companion value. data-xls-formula="=SUM(A1:A10)" is fine, but PhpSpreadsheet stores a cached value alongside. Write the computed PHP value in the cell body so the file shows something sensible before Excel recalculates and in any tool that doesn't evaluate formulas.
Images: prefer data-URI or a pre-cached local path. data-xls-image="https://…" means the export blocks on an outbound HTTP call per cell. If the source images are yours, store them locally and pass the path; if they're user-supplied, proxy + cache them first. Set data-xls-img-width/-height explicitly or you'll get full-resolution images blowing past row heights.
Queued / stored exports. For anything slower than a second, don't stream. Generate the file, Storage::disk('exports')->put(...), email or return a signed URL. streamWorkbook is a Symfony Response; in Laravel either adapt it (response()->streamDownload()) or just use PhpSpreadsheet's writer against the workbook the renderer produced — you get the same file with idiomatic Laravel semantics.
Format choice by consumer. xlsx for humans in Excel, csv for pipelines and BI ingestion (first sheet only — design accordingly), ods when the client runs LibreOffice. Expose the format as a query parameter and whitelist it; don't pass user input straight into the writer selection.
Strict mode in dev/CI, relaxed in prod. strict: true turns unknown data-xls-* attributes into exceptions — perfect for catching typos in a feature test, hostile if the HTML is user-controlled. Flip per-environment.
Test the artefact, not the HTML. In a Pest/PHPUnit test, generate the workbook and read it back with PhpOffice\PhpSpreadsheet\Reader\Xlsx: assert sheet names, getCell('B2')->getValue(), getStyle('A1')->getFont()->getBold(), and that a formula came through as a formula. That's your regression net — you cannot eyeball a spreadsheet in a diff.
The Symfony coupling is the big one. Config is YAML, the return types are Symfony\Component\HttpFoundation\Response, and services are wired through the Symfony container. In Laravel you'll hand-roll construction. Read the bundle's services.yaml/Configuration.php to see what the renderer actually needs — it's usually a small object graph, so manual instantiation in a singleton binding is realistic. Do this before promising a delivery date.
HTML parsing is the fragile seam. The converter reads markup, so anything the browser forgives is a potential off-by-one in cell coordinates:
<br> inside cells, and Blade comments ({{-- --}}) — the last renders to nothing, but <!-- --> HTML comments in a template can confuse a DOM-walking parser.<table> above your data table and everything shifts. Render only the export fragment.<caption>, <tfoot>, nested tables — verify these explicitly against your real markup; the README documents thead/tbody/colgroup only. <tfoot> and <caption> are the classic surprises.If row/column alignment is off, dump($html) the exact string handed to the parser and load it in a browser and a validator. Almost every bug is in the string, not the converter.
Numbers are strings until you say otherwise. 1 234,56, 1,234.56, €1.234,56 — none of these become numeric cells. Cast in PHP ((float) $value) and let Excel do the presentation via a style (money, percent2). If you pass a preformatted French string, sorting and SUM silently fail, and data-xls-conditional comparisons won't fire.
Formulas use en-US syntax regardless of locale. Commas for argument separators, SUM not SOMME. LibreOffice and Excel translate on open; don't try to "help" by writing ;. Also: a cell with a formula needs data-xls-type (or a numeric style) or you may end up with a text cell containing an equals sign.
Dates: pass objects or ISO, never d/m/Y strings. The Quick Start passes \DateTime and that's the right instinct. "01/02/2024" is unrecoverable — February 1st or January 2nd, the library can't know. Convert in PHP ($model->date->toDateTimeImmutable()) and apply the date style.
Merging: use the data attributes, not HTML colspan/rowspan. The README is explicit about data-xls-colspan/data-xls-rowspan. Mixing in native colspan risks being ignored or double-counted, and covered cells still need null/empty bodies in your loop or their values overwrite the merge. When merging vertically out of a @foreach, @if($loop->first) guards are the usual shape — test one file before trusting the loop.
autosize beats width, and it's computed late. If both data-xls-width and data-xls-autosize are on the same column, autosize wins. And because sizing happens after data load, anything you append to the workbook afterwards leaves the widths stale. Set widths last, or accept autosize everywhere.
strict: true is a debugging tool, not a security boundary. It throws on unrecognised attributes — which means HTML from a CMS or user-editable template becomes a 500. Turn it on in dev/test, off in prod, and sanitise anything user-supplied with an allowlist of data-xls-* names.
Conditional-format syntax fails quietly. data-xls-conditional="value<0|bg:FFCCCC|font:FF0000" — pipes as separators, hex without #, between:min:max with colons. A stray # or space likely just does nothing in non-strict mode. Enable strict the first time you use it, or you'll debug by screenshotting Excel.
Remote images are an SSRF and latency surface. The server fetches whatever URL you put there. Restrict to known hosts, cache to disk, set a timeout, and cap image dimensions. In a shared-hosting or queue context, a slow image endpoint will look exactly like "the export sometimes hangs".
Memory scales with the whole workbook. Every sheet, image and style is in memory until the writer runs. A few thousand rows is fine; a hundred thousand is not. Stream from a query with chunk() into separate files or move to queued generation with a temp file and consider disabling formula precalculation on big sheets.
CSV loses nearly everything. First sheet only, no styles, no merges, no formulas — and formula cells may export as empty if no cached value was set. If CSV is a real deliverable, generate it from a separate, deliberately plain table rather than trusting the styled xlsx source.
ODS is the least-travelled path. PhpSpreadsheet's ODS writer trails the xlsx one; comments, conditional formatting and some styling may degrade. If ODS is a requirement, test with LibreOffice specifically — and check whether your users really need it (LibreOffice opens xlsx perfectly well).
Extension points worth knowing. Custom styles via the YAML default_styles map (font/alignment/number format) is the sanctioned way to keep templates terse. The README also advertises custom validators/attribute handlers — useful if you want a house-specific data-xls-* (say, data-xls-currency="EUR"). Before building one, confirm the registration hook exists in src/; the README is a first draft and not every advertised extension is wired.
Maintenance risk is real. 7 stars, one maintainer, "WIP", zero dependents, and a version constraint spanning PhpSpreadsheet 4.x and 5.x (with 3.x untested). Pin exact versions in composer.json, and keep the whole integration behind your own SpreadsheetExporter interface with two implementations: the bundle and raw PhpSpreadsheet. The day you need a feature it doesn't have — and you will — the escape hatch is already in place.
How can I help you explore Laravel packages today?