PHPackages                             nouxwell/tabula - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [PDF &amp; Document Generation](/categories/documents)
4. /
5. nouxwell/tabula

ActiveLibrary[PDF &amp; Document Generation](/categories/documents)

nouxwell/tabula
===============

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

v0.6.0(today)06↑2400%MITPHPPHP &gt;=8.3

Since Aug 28Pushed todayCompare

[ Source](https://github.com/nouxwell/tabula)[ Packagist](https://packagist.org/packages/nouxwell/tabula)[ Docs](https://ionsis.com)[ RSS](/packages/nouxwell-tabula/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (11)Versions (3)Used By (0)

Tabula
======

[](#tabula)

**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
---

[](#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.

Installation
------------

[](#installation)

```
composer require nouxwell/tabula
```

The package is not on Packagist. Point Composer at the repository:

```
"repositories": [
    { "type": "vcs", "url": "git@github.com:nouxwell/tabula.git" }
]
```

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.

Quick start
-----------

[](#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
----------------

[](#field-definition)

KeyWhat 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.`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
------------

[](#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

[](#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
--------------

[](#output-formats)

WriterEngineMultiple sheets`Format::Xlsx`PhpSpreadsheetReal tabs`Format::Csv`Native `fputcsv`, fully streamedOne file per chunk`Format::Pdf`DompdfSheet name rendered above the tableThe 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
--------------------------------

[](#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 doesData loss`NextPageSet` (default)Splits columns into groups; each group gets its own set of pagesnone`Drop`Sheds low-priority columns (`Optional` first, `Always` never)**yes**`Shrink`Never splits, tries to fit everythingnone (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)
-----------------------

[](#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.

Templates and import
--------------------

[](#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: row number + field key + message
$result->errorsByRow();   // grouped for display
```

### Matching is by key, not by label

[](#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

[](#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.

### Parsers are strict where formatters are lenient

[](#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.

### Templates carry dropdowns

[](#templates-carry-dropdowns)

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.

Translation
-----------

[](#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

[](#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
---------------------

[](#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
-----------

[](#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`.

Status
------

[](#status)

AreaStateSchema, formatters, Xlsx + CSV writersdone`DoctrineSource`, Symfony bridge bundledoneConfigurable writer optionsdonePDF writer, page size and column budgetdoneImport + template generationdoneLicense
-------

[](#license)

MIT — see [LICENSE](LICENSE). Copyright © 2026 [IONSIS](https://ionsis.com).

Written by Hüseyin Niyazi Balın.

###  Health Score

39

—

LowBetter than 84% of packages

Maintenance100

Actively maintained with recent releases

Popularity6

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity39

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

0d ago

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/97829998?v=4)[Hüseyin Balın](/maintainers/nouxwell)[@nouxwell](https://github.com/nouxwell)

---

Top Contributors

[![nouxwell](https://avatars.githubusercontent.com/u/97829998?v=4)](https://github.com/nouxwell "nouxwell (14 commits)")

---

Tags

symfonypdfschemaexportexcelxlsxcsvspreadsheetimport

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/nouxwell-tabula/health.svg)

```
[![Health](https://phpackages.com/badges/nouxwell-tabula/health.svg)](https://phpackages.com/packages/nouxwell-tabula)
```

###  Alternatives

[maatwebsite/excel

Supercharged Excel exports and imports in Laravel

12.7k162.0M973](/packages/maatwebsite-excel)[kartik-v/yii2-export

A library to export server/db data in various formats (e.g. excel, html, pdf, csv etc.)

1613.3M38](/packages/kartik-v-yii2-export)[jgrygierek/batch-entity-import-bundle

Importing entities with preview and edit features for Symfony.

121.1M1](/packages/jgrygierek-batch-entity-import-bundle)[avadim/fast-excel-reader

Lightweight and very fast XLSX Excel Spreadsheet and CSV Reader in PHP

105786.0k14](/packages/avadim-fast-excel-reader)[avadim/fast-excel-laravel

Lightweight and very fast XLSX Excel Spreadsheet Export/Import for Laravel

4263.3k1](/packages/avadim-fast-excel-laravel)[jgrygierek/sonata-batch-entity-import-bundle

Importing entities with preview and edit features for Sonata Admin.

10959.8k](/packages/jgrygierek-sonata-batch-entity-import-bundle)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
