Search by

nouxwell / tabula

nouxwell

Schema-driven tabular export and import for Excel, CSV and PDF. One schema, three directions.

v0.8.3 2026-09-16 09:34 UTC

This package is auto-updated.

Last update: 2026-09-16 09:36:46 UTC


README

CI Packagist PHP

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/csv or spatie/simple-excel will do it in three lines without asking you to declare a schema. If you need millions of rows, openspout streams 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/OFFSET skips and repeats rows.
  • A caller-supplied setMaxResults is 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 call detach() 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 an ExportException. 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 @page rule, Dompdf applied the CSS at render time, and the setPaper() call was effectively decorative. The @page rule is now produced only by Page::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

GroupedSheets does 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::$value shows 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::FailFast stops 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:

  1. Nouxwell\Tabula\Contract\TranslatableEnum::translationKey()
  2. A label(): string method on the enum (a widespread convention — existing enums work unchanged)
  3. BackedEnum::$value or UnitEnum::$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.