nouxwell / tabula
Schema-driven tabular export and import for Excel, CSV and PDF. One schema, three directions.
Requires
- php: >=8.3
- phpoffice/phpspreadsheet: ^5.1
Requires (Dev)
- doctrine/orm: ^3.3
- dompdf/dompdf: ^3.1
- friendsofphp/php-cs-fixer: ^3.68
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.4
- symfony/config: ^7.2|^8.0
- symfony/dependency-injection: ^7.2|^8.0
- symfony/http-kernel: ^7.2|^8.0
- symfony/translation: ^7.2|^8.0
- symfony/translation-contracts: ^3.5
Suggests
- doctrine/orm: Required by DoctrineSource to read through a QueryBuilder
- dompdf/dompdf: Required to write PDF output with Format::Pdf
- symfony/http-kernel: Required by TabulaBundle, the Symfony bridge
- symfony/translation: Lets SymfonyTranslator ask the catalogue which domain defines a key
- symfony/translation-contracts: Required by the SymfonyTranslator adapter
Provides
None
Conflicts
None
Replaces
None
README
One schema, three directions. A PHP library that writes tabular data to Excel, CSV and PDF from a single schema definition — and uses that same definition to generate blank templates and to import files back in.
Author: Hüseyin Niyazi Balın
Why
In a typical ERP, "what a column is" lives in four separate places and none of them knows about the others: the columns the browser happens to be showing, hand-written column arrays inside controllers, a global decimals map keyed by field name, and the import templates' own schema provider. The result is that the same column is described four times, an exported file cannot be imported back, and changing a translation silently breaks every file users already have.
Tabula inverts that: a column is defined in exactly one place. Everything else — writers, formatters, parsers, sheet strategies — are consumers of that definition.
What it buys you
Rename one column header in your translation file. Every template your users have already downloaded and half-filled stops importing — the system looks for "Customer Code", the file says "Client Code", and nothing matches. The support ticket says "the import is broken". Nobody connects it to a translation commit from three weeks ago.
Tabula makes that impossible. The template hides the canonical field keys in row 1 and matches on those, so the visible header is free to change. Re-translate every label in the file and it still imports.
That is one instance of the thing this library is really for: a file that goes out and comes back. You hand users a template, they fill it in, they send it back, and what returns has to be trusted enough to write into a database. Three more properties follow, and all of them are hard to add later.
One definition behind all three directions. The export, the blank template and the import parser
are produced by the same Field. Not three code paths that happen to agree this month — a file this
library wrote is a file it can read, by construction.
Strict on the way in, lenient on the way out — deliberately. An export prints an unreadable cell blank and carries on; a 40,000-row report should not die over one bad value. An import doing the same writes wrong data into your database, so it raises an error naming the row and the field, and keeps going. The directions are asymmetric because the cost of being wrong is asymmetric.
Locale handling is the actual work, and it is where the money is lost. 1.234 is 1234 in
Turkish and 1.234 in English; guessing wrong is a silent thousand-fold error in a balance column.
Money cells carry both a real number Excel can sum and the localised text a human reads. Accounting
notations — (1.234,56) for a negative, the trailing minus an ERP feed emits — are understood
rather than quietly turned into positives.
Not this library: if the file only ever goes one way — a report nobody sends back — none of the above earns its keep, and
league/csvorspatie/simple-excelwill do it in three lines without asking you to declare a schema. If you need millions of rows,openspoutstreams properly where this holds the workbook in memory (USAGE §16).
Installation
composer require nouxwell/tabula
Requires PHP 8.3+. For PDF output also install dompdf/dompdf; if it is missing, asking for
Format::Pdf fails when the writer is created — not after fifty thousand rows have been processed.
Worked examples for the common jobs — a Doctrine query to Excel, one tab per warehouse, a PDF that fits the paper, a template and the import that reads it back — are in USAGE.md.
Quick start
use Nouxwell\Tabula\Tabula; use Nouxwell\Tabula\Format; use Nouxwell\Tabula\Schema\{Schema, Field, Priority}; use Nouxwell\Tabula\Source\ArraySource; use Nouxwell\Tabula\Port\ArrayTranslator; use Nouxwell\Tabula\Settings\{TabulaSettings, NumberSettings}; $schema = Schema::make('customer') ->title('export.customer.title') ->fields( Field::string('code')->label('export.customer.code')->width(14)->required() ->priority(Priority::Always), Field::string('name')->label('export.customer.name'), Field::string('city')->label('export.customer.city')->from('address.city'), Field::quantity('stock')->label('export.customer.stock')->decimals(3), Field::money('balance')->label('export.customer.balance') ->currency(fn (array $row): string => $row['currencyCode']), Field::bool('isActive')->label('export.customer.active'), Field::date('createdAt')->label('export.customer.created')->priority(Priority::Optional), ); $tabula = new Tabula( new ArrayTranslator(['en' => ['export.customer.code' => 'Code', /* … */]]), new TabulaSettings(numbers: new NumberSettings(currencySymbols: ['USD' => '$'])), ); $result = $tabula->export($schema) ->from(ArraySource::of($rows)) ->locale('en') ->to(Format::Xlsx) ->write('/tmp/customers.xlsx'); $result->path(); // /tmp/customers.xlsx $result->rows; // number of rows written
Field definition
| Key | What it does |
|---|---|
key |
Canonical field name. The file's real identity; it stays put when translations change. |
label |
A translation key, plain text, or fn(string $locale): string. |
from |
Where the value comes from: array key, dot path (address.city), DQL alias, or a closure. |
type |
string · integer · decimal · money · quantity · bool · date · datetime · enum · options |
decimals |
Digit count — on the field itself, not in a global map. |
currency |
A fixed code or fn($row): string. Symbol and its position come from settings. |
enumClass |
A PHP enum class; values resolve to translation keys automatically. |
options |
The option set for the options type (array or closure). |
width |
Column width; automatic when omitted. |
align |
Derived from the type when omitted: numbers right, bool/date centre, text left. |
required |
Marks the header in the template and makes the field non-empty on import. |
example |
A sample value shown in the template's input message when a cell is selected, formatted with the export's number and date settings. Never written into a cell. |
priority |
Rank in the PDF column budget: Always · Normal · Optional. |
only |
Restricts the field to specific output formats. |
format |
A closure that takes over formatting entirely. |
Data sources
You can hand the rows over yourself, or let the library page through them. The schema side treats both identically.
use Nouxwell\Tabula\Source\{ArraySource, IteratorSource, CallableSource, DoctrineSource}; ArraySource::of($rows); // rows already in hand IteratorSource::of(fn () => $generator()); // streaming in constant memory CallableSource::of( // server-side pagination fn (int $page, int $limit) => $repo->fetchPaged($page, $limit), pageSize: 2000, ); DoctrineSource::of($queryBuilder); // one query, row-by-row streaming DoctrineSource::of($queryBuilder)->chunk(2000); // page by page (ORDER BY required)
Every source is lazy: rows are produced as they are consumed, never collected up front.
DoctrineSource has two modes
Streaming (default) opens a single query and hydrates rows one at a time via
Query::toIterable(). No pagination arithmetic is involved, so rows can neither be skipped nor
repeated, and any setFirstResult/setMaxResults window the caller set is preserved.
Chunked (chunk(n)) issues one query per page. It enforces two hard rules, both of them there to
prevent silent data corruption:
- ORDER BY is mandatory. Without a stable sort,
LIMIT/OFFSETskips and repeats rows. - A caller-supplied
setMaxResultsis rejected. Chunking would overwrite it, so a query built as a "preview, at most 50 rows" would quietly stream the entire table.
Paging stops only on a genuinely empty page, never on a short one: getResult() returns hydrated
results, and in joined queries repeated root rows collapse into a single object — 2000 SQL rows can
become 900 roots. A "short page means we're done" rule would silently end the export on page one.
⚠ If you use
hydrateAs(AbstractQuery::HYDRATE_OBJECT), manage memory yourself: entities stay managed in the UnitOfWork, and the library deliberately does not calldetach()behind your back.
Output formats
| Writer | Engine | Multiple sheets |
|---|---|---|
Format::Xlsx |
PhpSpreadsheet | Real tabs |
Format::Csv |
Native fputcsv, fully streamed |
One file per chunk |
Format::Pdf |
Dompdf | Sheet name rendered above the table |
The CSV delimiter defaults to ;. In Turkish/European Excel, , is the decimal separator, so a
comma-delimited file splits numbers across two columns. Files are written with a UTF-8 BOM; without
it Excel mangles non-ASCII characters.
PDF: paper and the column budget
Xlsx and CSV have no notion of page size; a PDF has a physical width. That makes the column count not a preference but a computable budget:
budget = floor( (paper width − left/right margins) ÷ minimum column width )
A4 landscape gives 297 − 2×10 = 277 mm; at a 22 mm minimum that is 12 columns. Moving the same schema to A3 landscape raises the budget to 18 — enlarging the page widens the table on its own, with no extra configuration.
use Nouxwell\Tabula\Format; use Nouxwell\Tabula\Export\Page\{Page, ColumnBudget, Overflow}; $tabula->export($schema) ->from(ArraySource::of($rows)) ->locale('en') ->to(Format::Pdf) ->page(Page::a3()->landscape()->margins(8)) ->columns(ColumnBudget::fit()->minWidth(25)->anchor('code', 'name')) ->write('/tmp/customers.pdf');
Page is the paper geometry: a3() · a4() · a5() · letter() · custom(w, h), then
landscape()/portrait() and margins(mm) / marginsOf(t, r, b, l). Dimensions are always given in
portrait; Page applies the orientation. The default is A4 landscape.
ColumnBudget decides how the columns are fitted onto that paper. When they do not fit, there are
three behaviours:
Overflow |
What it does | Data loss |
|---|---|---|
NextPageSet (default) |
Splits columns into groups; each group gets its own set of pages | none |
Drop |
Sheds low-priority columns (Optional first, Always never) |
yes |
Shrink |
Never splits, tries to fit everything | none (may become unreadable) |
->columns(ColumnBudget::fit()->max(8)->overflow(Overflow::Drop)) // one-page summary ->columns(ColumnBudget::unlimited()) // few columns anyway
Columns passed to anchor('code', 'name') are repeated in every group: without them, a reader
looking at the second group cannot tell which row they are on. An anchor key that is not part of the
export is silently ignored — if the user did not select that column, there is nothing to anchor.
Drop never sheds an Always column. If the mandatory columns alone exceed the budget the export
stops with an error instead of quietly shipping an incomplete document.
⚠ Passing
->page()or->columns()to a format that has no notion of paper (Xlsx, CSV) aborts the export with anExportException. Ignoring them silently would be the very bug this design exists to remove: in the original code the page size was declared both in PHP (Dompdf::setPaper()) and in the template's@pagerule, Dompdf applied the CSS at render time, and thesetPaper()call was effectively decorative. The@pagerule is now produced only byPage::cssPageRule().
The font family must cover Latin Extended-A. Dompdf's core fonts (Helvetica, Times, Courier) are bound
to WinAnsi, which does not contain ş ğ ı İ; the only bundled family that carries them is
DejaVu Sans, which is therefore the default.
Sheet strategies (tabs)
use Nouxwell\Tabula\Export\Sheet\{SingleSheet, ChunkedSheets, GroupedSheets}; ->sheets(new SingleSheet('Customers')) // everything on one sheet (default) ->sheets(new ChunkedSheets(50_000)) // a new sheet every 50,000 rows ->sheets(new GroupedSheets('warehouseName')) // one sheet per field value
GroupedSheetsdoes not reorder rows. For a group to land on a single sheet, the data source must already be sorted by that field.
A workbook split this way is read back one sheet at a time — the import refuses a file with more than one data sheet rather than picking for you, because reading the first and stopping would drop the rest without saying so:
foreach (['Ankara', 'Izmir'] as $sheet) { $tabula->import($schema)->from($path)->sheet($sheet)->each(…)->run(); }
The refusal names the sheets, so the choice is visible without opening the file. A single-sheet file
needs none of this: a filled-in template carries a hidden _lists helper sheet, and hidden sheets do
not count as data.
Templates and import
The same schema produces a blank template and reads filled files back, which is what makes the round trip work by construction.
// 1 · generate a blank template $tabula->template()->write($schema, '/tmp/template.xlsx', 'en'); // 2 · the user fills it in, then: $result = $tabula->import($schema) ->from('/tmp/filled.xlsx') ->locale('en') ->each(static fn (ImportedRow $row) => $repository->save($row->toArray())) ->run(); $result->imported; // 4812 $result->errors; // list<RowError>: row number + field key + message + code $result->errorsByRow(); // grouped for display
Matching is by key, not by label
The generated template is laid out as:
row 1 → canonical field KEYS (hidden in xlsx)
row 2 → translated labels (what the user reads)
row 3+ → data
Detection needs no marker: if every non-empty cell of row 1 matches a schema key, it is a key row.
This is the point of the whole design. When the translated header string is the file's identity — as it commonly is — renaming one word in a translation catalogue silently invalidates every template users already downloaded, and a file with English headers will not match in a Turkish session. Here, re-translating every label leaves existing files perfectly readable.
MatchStrategy::Auto (default) uses the key row when present and falls back to labels, so files
produced elsewhere or filled in by hand still work. Key requires the key row; Label ignores it.
⚠ Known limit: the key row protects headers, not cell contents. Enum and boolean values are written as translated text, so re-translating those option labels does break an already-filled file.
Values come back typed
ImportedRow hands you real types, not strings: bool is a bool, an enum field is an enum instance,
a date is a DateTimeImmutable, a quantity is a float.
What was in the cell can be kept too — for showing the user their own input, for an audit trail, or for moving string-normalising code over to typed values one field at a time. Ask for it on the import:
$tabula->import($schema)->from($path)->locale('tr')->keepRawValues()->each(...)->run(); $row->get('qty'); // 1234.5 $row->raw('qty'); // "1.234,5" from a CSV — the value the reader produced, before parsing $row->rawValues(); // every field's raw value, keyed like toArray()
It is off by default. Rows streamed through the callback cost the same either way, but code that keeps
the ImportedRow objects holds about half as much memory again per row with it on. Without it,
raw() refuses rather than answering null for every field.
⚠ The raw value is the reader's, not the user's keystrokes. A CSV cell is the text in the file. An xlsx cell is what the workbook stores: a number is a number (
1234.5), a date its serial number (45296), a formula its result as PhpSpreadsheet calculates it. Only accepted rows reach the callback; for a rejected cell,RowError::$valueshows what was rejected.
Parsers are strict where formatters are lenient
An export may swallow a broken cell and print a blank; an import doing the same writes wrong data into
your database. So an unparseable value raises a RowError for that row and field, and the run
continues. Two consequences worth knowing:
- An empty value is not an error — the required check happens against the field, not in the parser.
ErrorMode::Collect(default) processes the valid rows and reports the rest;ErrorMode::FailFaststops at the first error.
Transactions are yours: the library parses and validates, it never writes to your database.
Errors carry a code, not just a sentence
RowError::$message is an English sentence, ready to show. When your users read another language,
word it yourself from RowError::$code and RowError::$params instead of checking the cells again:
$text = null === $error->code ? $error->message // a custom parser that threw without a code : $tabula->translator()->trans('import.error.'.$error->code->value, $error->params, $locale);
| Code | When | Params |
|---|---|---|
required |
a required field is empty | field, type |
not_a_number |
an integer, decimal, quantity or money cell is not a number at all | field, type, value |
not_an_integer |
a number, but a fraction or too large, in an integer field | field, type, value |
not_a_date |
a date or date-time cell cannot be read | field, type, value, format |
not_a_boolean |
not one of the accepted yes/no words | field, type, value, accepted |
not_an_option |
not one of an enum's or an options field's values | field, type, value, options |
What the params hold: field is the field key (the same as RowError::$field), not its label;
type is the FieldType value (quantity, money, …); value is the rejected cell as text; format
is the PHP date pattern the field expects; accepted and options are the lists exactly as the message
shows them, joined with , — display text, not meant to be split (options is empty when the field
has none).
The params are plain names. Tabula's Translator port adds the %…% — ArrayTranslator,
PassthroughTranslator and the Symfony bridge all do. Symfony's own TranslatorInterface does not, and
its third argument is the domain, not the locale; if you call it directly, wrap the names first.
New codes may be added in a later release; existing ones are never renamed, so a match over
RowErrorCode should keep a default arm that falls back to $error->message. The code is null only
when a custom parser threw its own new ParseException(...); reusing a factory such as
ParseException::notAnOption() carries the code.
Templates refuse bad input in the cell
Bool, enum and options columns get real Excel data-validation dropdowns, backed by a hidden _lists
sheet with identical option sets de-duplicated. The dropdown entries and the values the parser accepts
are derived from the same translation call, so a value written by the template is always a member of
its own allowed list.
Typed columns are validated as they are typed: a date column takes dates, an integer column whole numbers, the other numeric types decimals. The parser catches these anyway — but only once the file has been filled in and sent back, which is the difference between "row 348 could not be read" and the cursor never leaving the cell.
Blank stays allowed on every rule, because requiredness belongs to the import where the failure can name the row and the field. Made Excel's job it fires a warning box for merely tabbing through an unfinished row, and a user who meets that box twice switches validation off for good — taking the rules that do matter with it.
Examples without sample rows
Field::example() shows a sample value when a cell of the column is selected:
Field::string('code')->label('col.code')->required()->example('120.01.001'), Field::decimal('total')->label('col.total')->decimals(2)->example(1250.5), Field::date('issuedAt')->label('col.date')->example(new DateTimeImmutable('2026-01-31')),
Excel's input message then reads "Example: 120.01.001", with "Required" underneath on a required
column. A value of the field's own type is formatted with the same number and date settings as the
export: 1250.5 reads "1.250,50" under Turkish settings.
A template has no data row, and the example behaves accordingly. On text, number, money and date
columns a format() closure is not applied and money carries no currency symbol, exactly like the
column's own cell format. On bool, enum and options columns the example goes through the same call
as the dropdown list, so it reads exactly like its entry in the list — which also means a format() or
options() closure on such a column is called with a null row and has to accept it, for the
list as much as for the example. Templates use the built-in formatters; a custom
FormatterRegistry given to Tabula affects the export only.
It is never written into a cell. A sample row in the data area is imported as a real record the
moment someone forgets to delete it; an input message cannot be. The two words come from
TemplateOptions (exampleWord, requiredWord) and translate the way the boolean words do.
Protecting the key row
Hiding the key row keeps the keys out of sight, not out of reach. A user who unhides it can type
over a key, and two keys swapped between columns of the same type import without a single error:
each column's values land in the other's field. protectHeader locks the key and label rows:
new TemplateOptions(protectHeader: true);
tabula: template: protect_header: true
The sheet is protected without a password, which stops accidents rather than intent. Below the header, everything a template is filled in with keeps working: typing, pasting from another workbook, filling down, inserting and deleting rows, sorting and filtering through the header buttons, column widths. What it takes away is formatting cells, inserting or deleting columns, sorting a range that includes the header, and typing into a row inserted directly under the header (Excel copies the header's locked format into it) — which is why it is off by default.
What a boolean cell says
Yes and No, unless you say otherwise:
new TabulaSettings(boolTrueKey: 'Evet', boolFalseKey: 'Hayır'); // plain words new TabulaSettings(boolTrueKey: 'app.yes', boolFalseKey: 'app.no'); // translation keys
Plain words are written as they stand; anything the translator recognises is resolved. The defaults
are words rather than keys deliberately — a translator hands back whatever it cannot translate, so a
key with no catalogue entry would be printed into the cell verbatim, and BoolParser has never heard
of tabula.bool.yes coming back.
Translation
The core does not know about Symfony; it talks through a Translator port.
interface Translator { public function trans(string $key, array $params = [], ?string $locale = null): string; }
Bundled implementations: ArrayTranslator (flat or nested catalogues) and PassthroughTranslator
(returns the key unchanged). The Symfony bridge binds the port to the framework translator.
The locale is passed explicitly on every call — there is no "language from the current request" inside a queue worker.
Enum translation
An enum value resolves to a translation key by trying, in order:
Nouxwell\Tabula\Contract\TranslatableEnum::translationKey()- A
label(): stringmethod on the enum (a widespread convention — existing enums work unchanged) BackedEnum::$valueorUnitEnum::$name
Using it with Symfony
config/bundles.php:
Nouxwell\Tabula\Bridge\Symfony\TabulaBundle::class => ['all' => true],
config/packages/tabula.yaml:
tabula: default_locale: '%kernel.default_locale%' empty_text: '-' translation: domains: ['messages', 'enum'] # tried in order, first DEFINING domain wins addressable_domains: ['validators'] # also reachable as 'validators:key' numbers: currency_symbols: USD: '$' csv: # defaults target Excel delimiter: ';' write_bom: true line_ending: crlf # crlf | lf xlsx: creator: 'My App' freeze_header: true auto_filter: true pdf: page_size: a4 # a3 | a4 | a5 | letter | legal (portrait dimensions) orientation: landscape # portrait | landscape margin_mm: 10.0 min_column_width_mm: 22.0 max_columns: ~ # ~ = no hard cap overflow: next_page_set # next_page_set | drop | shrink font_family: 'DejaVu Sans' font_size_pt: 8.0 repeat_header: true
Nouxwell\Tabula\Tabula can then be injected anywhere.
Writer settings. There is no single right CSV default: a human opens the file in Excel (; plus a
BOM), a machine expects RFC 4180 (,, no BOM). When you need both, pass the options at the call site:
use Nouxwell\Tabula\Export\Writer\{CsvWriter, CsvOptions, XlsxWriter, XlsxOptions}; ->writer(new CsvWriter(CsvOptions::rfc4180())) // machine-to-machine feed ->writer(new XlsxWriter(XlsxOptions::plain())) // undecorated intermediate file
line_ending is given by name (crlf/lf) because writing a literal "\r\n" in YAML runs into
escaping rules and silently becomes two characters. pdf.page_size, orientation and overflow are
named for the same reason, rather than millimetres or class names.
Range rules for the pdf values (positive dimensions, sane minimums) live in the value objects
(Page, ColumnBudget, PdfOptions), not in the config tree: those messages also state the fix
("use A3 instead of A4, rotate to landscape, reduce the margin…"), and copying half of a rule into the
tree would be a small instance of exactly what this library exists to remove — one truth living in two
places. ->page()/->columns() at the call site override the configured defaults.
About translation domains. Labels commonly live in messages while enum captions live in enum,
yet an enum's label() returns a key that carries no domain. The bridge resolves this with a domain
chain: it asks the catalogue which domain defines the key and the first one that does wins.
Guessing from the returned string instead ("does it look like a miss?") is wrong in three real cases —
translations equal to their own key (IBAN, ISO codes), ICU domains, and pluralised keys containing
| — so TranslatorBagInterface::defines() is used.
Applications without a translator. If symfony/translation is absent or framework.translator is
disabled, the bundle falls back to PassthroughTranslator instead of taking the container down.
Development
composer install composer test # PHPUnit composer stan # PHPStan composer cs # php-cs-fixer
The suite runs with failOnWarning, failOnRisky and failOnDeprecation enabled, and PHPStan runs
at level 8 over both src and tests.
Versioning
Below 1.0, a minor bump is where a breaking change lands — which is how Composer already treats
^0.8. CHANGELOG.md says what each release changed and, for the breaking ones, what
to do about it.
License
MIT — see LICENSE. Copyright © 2026 IONSIS.
Written by Hüseyin Niyazi Balın.