jul6art / dataflow-bundle
Tabular import, export and report engine for Symfony
Package info
github.com/jul6art/dataflow-bundle
Type:symfony-bundle
pkg:composer/jul6art/dataflow-bundle
Requires
- php: ^8.5
- doctrine/orm: ^3.5
- jul6art/acl-bundle: ^1.1
- openspout/openspout: ^5.7
- symfony/config: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/http-foundation: ^7.4 || ^8.0
- symfony/http-kernel: ^7.4 || ^8.0
- symfony/translation: ^7.4 || ^8.0
- symfony/translation-contracts: ^3.4
- symfony/validator: ^7.4 || ^8.0
- symfony/yaml: ^7.4 || ^8.0
Requires (Dev)
- doctrine/doctrine-bundle: ^3.3
- friendsofphp/php-cs-fixer: ^3.68
- jul6art/core-bundle: ^2.14
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^13.0
- rector/rector: ^2.0
- symfony/flex: ^2.4
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/messenger: ^7.4 || ^8.0
- symfony/phpunit-bridge: ^7.4 || ^8.0
- symfony/security-csrf: ^7.4 || ^8.0
- symfony/twig-bundle: ^7.4 || ^8.0
- symfony/var-dumper: ^7.4 || ^8.0
- twig/twig: ^3.28
Suggests
- jul6art/audit-bundle: Gives ExportAuditorInterface something to write to. Without it exports are not journalled.
- jul6art/core-bundle: Required by the shipped Stimulus controller, which reads its labels through @jul6art/core-bundle/i18n/registry. Server-side use needs nothing of it.
- symfony/messenger: Carries an import off the request thread. Without it, ImportMessageHandler is removed by AsyncImportPass and importing stays synchronous.
- symfony/security-csrf: Protects the report builder's "save" route (lot 2.9). Without it, DataflowCsrfExtension mints an empty token and ReportBuilderCsrfChecker accepts every request — the same trade datatable-bundle's own preferences endpoint makes.
- symfony/twig-bundle: Renders the shipped Twig partials. Without it only the PHP engine is available.
Provides
None
Conflicts
None
Replaces
None
README
Tabular import, export and report engine for Symfony
Everything a management application needs to move tabular data across its own boundary: writing a CSV, an XLSX or a JSON stream, reading one back, and running a user-composed report over Doctrine entities.
It is extracted from two production applications that between them wrote tabular output in eleven places, in four different dialects, and guarded none of them against spreadsheet formula injection. This layer exists so that those three numbers become one, one and one.
This bundle deliberately does not depend on API Platform. A report is a
QueryBuilder, not a resource; exposing saved reports over HTTP is the application's business, and the API side of synchronising data belongs tojul6art/api-bundle.
Requirements
- PHP ^8.5
- Symfony ^7.4 || ^8.0
Installation
composer require jul6art/dataflow-bundle
Then register it in config/bundles.php (Flex does this for you):
Jul6Art\DataflowBundle\DataflowBundle::class => ['all' => true],
Configuration
# config/packages/dataflow.yaml dataflow: # Leaves the bundle installed and inert when false — no services at all, not merely no feature. enabled: true # The catalogue the bundle's own keys are looked up in. Never `messages`. translation_domain: dataflow # The Stimulus identifier the report builder answers to. It decides the data-attribute prefix # the shipped partial emits, so it has to match how you registered the controller. stimulus_identifier: dataflow--report-builder # The identifier of YOUR select2 controller, attached to the two selects of a filter row. # Empty keeps plain selects — an identifier you never registered is harmless either way, # since Stimulus simply finds nothing and the plain select stays. select2_identifier: ui--select2 # Applied when no LimitsProviderInterface is bound. These are the defaults. limits: report_rows: 1000 # rows a run returns when the caller asks for no limit export_rows: 50000 # rows one export may contain exports_per_hour: 30 # exports one actor may run per hour export_rows_per_hour: 10000 # row budget one actor may export per hour import_rows: 10000 # rows one imported file may contain imports_per_hour: 5 # imports one actor may run per hour field_max_depth: 2 # toOne relations the field catalogue walks; 0 = root only
⚠️ Every ceiling is also a container parameter — %dataflow.limits.export_rows_per_hour% and
its six siblings. That is not a convenience: the reference application wrote its row budget
10000 twice, once as a rate_limiter.yaml bucket size and once in the PHP that subtracted from
it, with nothing linking them. Changing the YAML made the arithmetic wrong in silence. Read the
parameter in both places and there is one number:
# config/packages/rate_limiter.yaml framework: rate_limiter: report_export_per_user: policy: sliding_window limit: '%dataflow.limits.export_rows_per_hour%' interval: '1 hour'
⚠️ That works whatever order your config/bundles.php lists. The parameters are published from
the extension's prepend(), which runs for every bundle before any load() — so they exist by the
time FrameworkBundle reads its own configuration. Published from load() (as v1.0.1 did) the
placeholder failed with "You have requested a non-existent parameter … while loading extension
framework", and it would have worked for a consumer that happened to register this bundle first.
Ports
Three seams where the bundle stops and the application starts. Two have a working default, so a consumer that binds nothing still has a usable bundle; the third has none, on purpose.
| Port | Default | Bind your own when |
|---|---|---|
LimitsProviderInterface |
ConfiguredLimitsProvider — the configuration above, the same for everyone |
ceilings vary by tenant, plan or quota |
ExportAuditorInterface |
NullExportAuditor — exports are not journalled |
you have audit-bundle, or any audit trail |
ReportDefinitionStoreInterface |
none — saved reports are ephemeral | reports must survive the session |
# config/services.yaml services: Jul6Art\DataflowBundle\Port\ReportDefinitionStoreInterface: '@App\Report\DoctrineReportStore'
⚠️ The store has no default because there cannot be one. A saved report is a row with an owner, a tenant, a visibility and a lifecycle; shipping an entity for it would force one tenancy model on every consumer. A bundle interprets, a project persists.
⚠️ A store keeps the raw payload, never a ReportSpec. A spec is valid against today's
catalogue; a saved report has to survive tomorrow's. The payload is re-interpreted on every load by
the same ReportSpecInterpreter a fresh screen uses — an unknown column is dropped, a missing
entity refused. That is a security property as much as a robustness one: a definition saved when its
author could read customer.email must not still expose it after the permission is revoked.
⚠️ An ExportAuditorInterface must not throw. An audit trail that can fail the thing it
observes turns a full log table into an outage of the export feature.
⚠️ ExportRecord::$rows is known only AFTER the response has streamed — that is what streaming
means. An auditor called before the first byte records every export as zero rows, and the row count
is the single most useful field in an export trail: it is what distinguishes a normal export from an
exfiltration.
What needs Doctrine, and what does not
ReportRunner, FieldCatalog and ImportRunner need an EntityManagerInterface. This bundle
requires doctrine/orm — the library — and deliberately not doctrine/doctrine-bundle — the
integration, which is what registers that service. In an application without it those three
services are removed by a compiler pass rather than left dangling, so the Io/ half still works:
writing a CSV from an array needs no ORM.
Usage
Writing tabular output
A writer takes a header, an iterable of rows, and a callback that receives chunks. Nothing is buffered: the first bytes reach the client while the last row is still being produced.
use Jul6Art\DataflowBundle\Io\Dialect\CsvDialect; use Jul6Art\DataflowBundle\Io\Writer\CsvWriter; use Symfony\Component\HttpFoundation\StreamedResponse; $writer = new CsvWriter(CsvDialect::excelFr()); $response = new StreamedResponse(static function () use ($writer, $invoices): void { $writer->write( ['number', 'customer', 'total'], (static function () use ($invoices): \Generator { foreach ($invoices as $invoice) { // a Doctrine `toIterable()`, ideally yield [$invoice->getNumber(), $invoice->getCustomer()->getName(), $invoice->getTotal()]; } })(), static function (string $chunk): void { echo $chunk; }, ); }); $response->headers->set('Content-Type', $writer->contentType());
⚠️ Pass a Generator, not an array. The signature says iterable and the writers honour it,
but a caller that materialises the rows first defeats the whole layer — and no content assertion
will ever show it, because every row is correct. That exact defect shipped: a runner with a 50 000
row ceiling held two complete copies of its result set before emitting a byte. If you write a
regression test for this, assert the type (assertInstanceOf(\Generator::class, …)) or the
interleaving, never the content. CsvWriterTest::testRowsAreConsumedLazily() shows one way.
Choosing a dialect
| Separator | BOM | Line ending | Use it for | |
|---|---|---|---|---|
CsvDialect::excelFr() |
; |
yes | CRLF | a file a French user will double-click |
CsvDialect::rfc4180() |
, |
no | CRLF | a file another program parses |
CsvDialect::tabSeparated() |
TAB | no | LF | an imposed format, e.g. the French FEC |
⚠️ bom: true is what makes Excel read UTF-8 at all. Without it Excel FR falls back to
Latin-1 and « prénom » renders « prénom ». And conversely: a mark a parser does not expect
becomes part of the first column's name, which is why rfc4180() omits it. One export of this
ecosystem shipped without a mark while six others had one, and only a human eye caught it.
⚠️ escape defaults to '', and that is not about silencing a PHP 8.4 deprecation. PHP's own
default is the backslash, which is not CSV — the standard doubles a quote. With the default, a
field ending in a backslash escapes the closing quote and swallows the separator, so the reader
loses a column boundary.
Anything other than the three presets is a deliberate exception, and constructing a CsvDialect
by hand reads as one at the call site.
The other two writers
XlsxWriter and JsonWriter implement the same contract, so a caller swaps one for another by
changing a single constructor call — that is what the code() / contentType() /
fileExtension() triplet is for.
⚠️ A workbook cannot be streamed to the client. A .xlsx is a ZIP archive whose central
directory is written last, so no prefix of the file is a valid document: XlsxWriter buffers to a
temporary file as rows arrive — the row source is still consumed lazily — then emits the finished
file in 64 KiB chunks. Emitting it as one string, which the version this replaces did, puts the
whole workbook back into a single PHP string and undoes the memory discipline of everything above.
⚠️ JsonWriter deliberately does NOT apply the formula guard. No spreadsheet opens its output,
so prefixing a value with an apostrophe would corrupt the payload of the only consumer there is. It
does use JSON_THROW_ON_ERROR: with json_encode's default, a malformed UTF-8 byte out of a legacy
column returns false, emits the empty string, and produces a syntactically valid document
silently missing a row — the worst available outcome.
Resolving a format code to a writer
public function __construct( #[AutowireIterator(tag: 'dataflow.tabular_writer')] private readonly iterable $writers, ) {} private function writer(string $format): TabularWriterInterface { foreach ($this->writers as $writer) { if ($writer->code() === $format) { return $writer; } } throw new \InvalidArgumentException(...); }
The three writers carry dataflow.tabular_writer, CsvReader carries dataflow.tabular_reader,
and your own implementation of either interface is autoconfigured onto the same tag — a fixed-width
format a customer imposes joins the iterator by existing.
⚠️ v1.0.x shipped them untagged, so the first consumer had to hand-roll the list of three. That is the duplication this bundle exists to remove.
Serving it as a download
use Jul6Art\DataflowBundle\Io\Http\TabularResponseFactory; return $this->responses->stream( $writer, ['number', 'customer', 'total'], $rows, TabularResponseFactory::basename(['invoices', $organization->getSlug()]), );
basename() builds the conventional <subject>_<tenant>_<date>, drops empty parts rather than
leaving a double separator, and sanitises each one.
⚠️ The filename is sanitised as a security control, not for tidiness. A tenant slug or a report
name reaches the Content-Disposition header from the database; a newline in it splits the HTTP
response and everything after the split is attacker-controlled. The five endpoints this factory
replaces did not agree on the matter: three sanitised, one hard-coded its filename, one did
neither.
Formula injection is handled for you
Every cell of every row and of the header goes through Io\Guard\FormulaInjectionGuard before
it is written. You do not call it. Two things are still worth knowing, because both cost this
ecosystem real debugging:
⚠️ A negative amount is not a formula. A Doctrine decimal column is hydrated as a string,
so -100.00 starts with a forbidden character. The guard tests is_numeric() before looking
at the prefix — widened to the French separator, since -100,00 is not numeric to PHP. Without
that, the guard text-marks every negative amount of an accounting file: a security fix that
corrupts the data it protects, noticed only by the accountant, while reconciling.
⚠️ The header is not trusted input. In a report builder a column label is typed by the user, so guarding only the body is the mistake that looks harmless.
If you add your own writer, implement Io\TabularWriterInterface and call
FormulaInjectionGuard::neutralizeRow() on every row you emit. The guard is idempotent, so a row
that goes through it twice is unchanged the second time.
Running a report
A report is a ReportSpec — a root entity, columns, filters — run against Doctrine and streamed.
use Jul6Art\DataflowBundle\Report\Spec\ReportSpecInterpreter; $spec = new ReportSpecInterpreter()->interpret($savedDefinition); // your stored payload $result = $runner->run($spec, $actor, limit: 50_000, scope: $this->scopeToTenant(...)); return $this->responses->stream($writer, $result->header(), $result->rows(), 'invoices');
⚠️ rows() is a Generator, single-use, and uncountable. Iterating twice throws; counting
means a second query. Both are deliberate — a caller that needs the rows twice runs the report
twice, so the cost is a decision rather than a default. Do not wrap it in iterator_to_array()
without meaning to: that is precisely the defect this replaced, where a runner held two complete
copies of a 50 000-row result set before emitting a byte.
⚠️ Tenant scoping is yours. The bundle does not know what a tenant is, so run() takes a
$scope closure receiving the query builder and the root alias. Inventing an organization
column here would fit one consumer and silently return everything for the others.
⚠️ A filter's value on a boolean field is cast from its string shape. ReportFilter::$value
routinely arrives as "true" / "false" — parsed off a query string, whatever built the spec — and
bound as-is against a real Doctrine boolean column, a plain = compares the stored value to the
literal text, which no driver ever matches either way. The runner reads the field's own Doctrine
type from FieldCatalog and applies filter_var(…, FILTER_VALIDATE_BOOLEAN) before binding, for
every operator that carries a value. Every other Doctrine type is left alone — most already
round-trip a numeric string correctly through their driver's own parameter binding, and guessing a
cast for a type this bundle does not exercise would trade one silent wrong answer for another.
Formatting a column
By default a column renders exactly as it always has — ISO for a date, the raw value for a number.
Give a ReportColumn a ColumnFormat to ask for something else:
use Jul6Art\DataflowBundle\Report\Format\ColumnFormat; use Jul6Art\DataflowBundle\Report\Spec\ReportColumn; new ReportColumn('total', 'Total', format: ColumnFormat::money('EUR')); new ReportColumn('vatRate', 'VAT', format: ColumnFormat::percent(1)); new ReportColumn('issuedAt', 'Issued', format: ColumnFormat::date());
Four kinds: number(), money($currency), percent(), date() / dateTime(). number(),
money() and percent() delegate to NumberFormatterInterface — bind your own (see below) to
render in your application's convention instead of the bundle's plain default. date() and
dateTime() use a configurable PHP format string, because there is no locale-aware date service to
delegate to; reconfigure ColumnFormatter's two patterns once, for the application.
⚠️ A date column reaching this class is usually already a STRING, not a DateTimeInterface —
ReportRunner hydrates with HYDRATE_SCALAR, and a Doctrine date/datetime column comes back as
its ISO text under scalar hydration, on SQLite as on PostgreSQL. Verified, not assumed:
DateTimeValueTransformer's own instanceof check is inert on this exact pipeline. ColumnFormat::date()
therefore accepts either shape, parsing the string case itself.
Rendering numbers in your own convention
services: Jul6Art\DataflowBundle\Report\Format\NumberFormatterInterface: '@App\Report\NumberFormatterAdapter'
NumberFormatterInterface mirrors jul6art/core-bundle's NumberFormatter on purpose — same three
method names, same signatures — but this bundle does not require core-bundle (it sits in
require-dev here, used by this bundle's own tests only), the same reasoning that keeps it free of
api-platform. A consumer with core-bundle writes the few lines that bridge the two:
final readonly class NumberFormatterAdapter implements NumberFormatterInterface { public function __construct(private \Jul6Art\CoreBundle\Service\NumberFormatter $inner) {} public function format(int|float|string|null $value, ?int $decimals = null): string { return $this->inner->format($value, $decimals); } public function formatMoney(int|float|string|null $value, string $currency, ?int $decimals = null): string { return $this->inner->formatMoney($value, $currency, $decimals); } public function formatPercent(int|float|string|null $value, int $decimals = 0): string { return $this->inner->formatPercent($value, $decimals); } }
With nothing bound, PassthroughNumberFormatter keeps every ColumnFormat::number()/money()/
percent() column working — plain, not localised, the same role NullExportAuditor plays for
auditing.
The fields endpoint
The screen asks your application for the columns it may offer, and the answer must be serialised
through ReportField::toArray():
return new JsonResponse([ 'fields' => array_map( static fn (ReportField $field): array => $field->toArray(), $fields->listFor($rootFqcn, $user, $limits->fieldMaxDepth) ), ]);
⚠️ Do not hand-roll that array. In v1.0.0 the catalogue produced traversed and the shipped
controller read relation — a name inherited from the application it was extracted from — so the
field sort compared undefined to undefined, the branch never fired, and every column behind a
relation sorted in among the root's own. Nothing failed: a missing property is not an error in
JavaScript, and no test crossed the boundary. toArray() and ReportFieldWireShapeTest are what
close it.
Declaring what may be reported
Two gates, and they answer different questions.
final class CrmReportableEntityProvider implements ReportableEntityProviderInterface { public function entities(): array { return [ Contact::class => new ReportableEntity('report.entity.contact', 'crm:contact:read', 'crm.manage'), ]; } }
Tag it dataflow.report.entity_provider — or better, a _instanceof block in services.yaml.
⚠️ #[AsTaggedItem] alone does NOT add the tag; it only indexes an item in an iterator that
already exists. An application relying on it gets an empty iterator and a runtime failure.
⚠️ The feature is nullable, and the checker is optional. Feature flags say what a tenant
bought; permissions say what a person may do. A single-product application has no feature
system: it declares null, binds no FeatureCheckerInterface, and only the permission gate
applies. The bundle's compiler pass nulls the contract rather than letting the container fail to
compile — which is what an unconditional constructor argument would do, and what two bundles of
this ecosystem shipped before.
⚠️ The permission gate is never optional. A report engine without a per-entity gate is a cross-tenant exfiltration tool.
What the field catalogue refuses, and why
| Refused | Because |
|---|---|
a toMany relation |
one invoice with four lines returns four rows; an export of a thousand silently multiplies |
a globally denied name (password, apiToken, …) |
once in a spreadsheet is once too often; the list is code, not configuration |
| a catalogued target the actor may not read | otherwise invoice.customer.email is granted by invoice:read alone |
whatever a FieldPolicyInterface narrows |
a global name list cannot tell a name sensitive on one entity from the same name on another |
An uncatalogued target — a referential, a country, a unit — is traversed freely: demanding a
catalogue entry per look-up table would make the catalogue unusable. To refuse one of those anyway,
bind a RelationPolicyInterface:
Jul6Art\DataflowBundle\Report\Catalog\RelationPolicyInterface: '@App\Report\UnreportableRelations'
⚠️ A FieldPolicyInterface cannot do this. It is asked about a scalar, so denying every field
of the target leaves the walk running and organization.owner.email is still offered. The first
consumer of this bundle had removed organization from its reportable relations deliberately, and
the extraction put it back — not a cross-tenant leak, the rows stay scoped, but a relation somebody
had decided not to expose, exposed again, in silence.
⚠️ The catalogue lists selectable SCALARS. customer alone is not a path; a null check on a
relation goes through its identifier (customer.id IS NULL).
Importing
Reading a file
use Jul6Art\DataflowBundle\Io\Dialect\CsvDialect; use Jul6Art\DataflowBundle\Io\Reader\CsvReader; $reader = new CsvReader(CsvDialect::excelFr()); foreach ($reader->read($path) as $record => $cells) { // $record is 1-based and the header is record 1 }
A reader carries its dialect in its constructor, exactly as a writer does, and yields a
Generator — reading a 40 MB file costs one record of memory.
⚠️ The key is a RECORD number, not a line number. A quoted field may contain newlines, so one record can span several lines of the file. A blank line yields nothing and still consumes its number, so every later number keeps pointing at the right row.
A real spreadsheet, and choosing a reader by content
use Jul6Art\DataflowBundle\Io\Reader\XlsxReader; $reader = new XlsxReader(); foreach ($reader->read($path) as $record => $cells) { // Same contract as CsvReader: 1-based, header included, every cell a string. }
XlsxReader reads a real .xlsx, through OpenSpout, with the same list<string> contract as
CsvReader — a date comes back as the text Excel would show, a boolean as '1' or '0', never as
a DateTimeInterface or a native bool a mapper written for CSV would not expect.
An upload's format is not known in advance, so it is resolved the same way a writer is resolved by
code() — a tagged iterator, checked with supports() instead:
public function __construct( #[AutowireIterator(tag: 'dataflow.tabular_reader')] private readonly iterable $readers, ) {} private function readerFor(string $filePath): TabularReaderInterface { foreach ($this->readers as $reader) { if ($reader->supports($filePath)) { return $reader; } } throw UnreadableFileException::cannotOpen($filePath); }
⚠️ supports() is answered from the CONTENT. XlsxReader's is narrower than the binary-container
question SpreadsheetSignature answers for CSV: it opens the ZIP and looks for xl/workbook.xml,
so a .docx or an .ods — a ZIP, but not this format — is refused by name instead of being
half-read. An old binary .xls (OLE2, not a ZIP at all) is refused by BOTH readers, which is D-14's
guard doing exactly what it always did: naming the refusal instead of importing zero rows in
silence.
The mapping screen
use Jul6Art\DataflowBundle\Import\HeaderInspector; $inspector = new HeaderInspector(); $headers = $inspector->peek($reader, $path); // reads ONE record $inspection = $inspector->inspect($headers, $mapper->fields()); $inspection->mapping; // [0 => 'firstName', 2 => 'email'] — column INDEX → field $inspection->ambiguous; // indices whose header collides with another's $inspection->unknown; // indices no field matched — normal, not an error $inspection->missing; // fields no column supplied
First Name, first_name, FIRSTNAME, Prénom and Email * all match: headers and field keys
are reduced to lower-case alphanumerics, accents folded through an explicit table, before being
compared.
⚠️ The mapping is keyed by column INDEX, not by header name. A file with two columns both
called email collapses into one entry in a name-keyed map and the second silently wins — the
import then reads the wrong column and every row is subtly wrong rather than obviously broken.
⚠️ A collision is reported, never resolved. Both columns are left out of the suggestion so the
screen can ask. And matching stops at exact-after-normalisation: Email * matches, Job Title (optional) does not, because substring matching would suggest the e-mail column for Email (invalid) — and a suggestion the user accepts without reading is worse than no suggestion.
Running an import
use Jul6Art\DataflowBundle\Import\ImportRunner; use Jul6Art\DataflowBundle\Import\Spec\ImportSpec; $spec = new ImportSpec($path, $inspection->mapping, dryRun: true); $report = $runner->run($spec, $mapper, $reader, $resolver); $report->imported(); // rows that would land — see isDryRun() before wording this $report->updated(); // rows that would overwrite an existing record — see onDuplicate below $report->skipped(); $report->errorCount(); // exact $report->errors(); // the first 100 $report->errorsWereTruncated();
The application supplies two things and the engine does the rest:
| You write | Why it cannot be configuration |
|---|---|
RowMapperInterface |
"what does a row of this file mean for this entity" is business: a tenant to attach, a default status, a referential to look up |
DuplicateResolverInterface (optional) |
only your schema knows what makes two records the same |
The five traps the runner handles for you
| Trap | What it does |
|---|---|
| the unit of work growing with the file | detach()es what it persisted after each flush — not clear(), which would detach the caller's own tenant and make the next flush raise "A new entity was found through the relationship" |
one SELECT per row |
findExisting() takes the whole batch and is expected to answer in one query |
| two identical rows in one file | neither is in the database when the batch is queried, so both would be persisted and the flush would die on the unique index — keyOf() lets the runner skip the second |
| a dry run that disagrees with the run | the in-file duplicate is counted the same way in both, so the preview does not lie |
| a batch failing halfway | atomic: true (the default) wraps the run in one transaction; ImportFailedException carries the partial report and says whether it was rolled back |
⚠️ A mapper throws \DomainException with a TRANSLATION KEY as its message. The runner catches
it and puts it in the report next to the record number; an uncaught exception would end the import
on one bad row.
⚠️ A report mixes three kinds of message, and the shipped partial handles all three: this
bundle's keys, your mapper's keys (in YOUR domain), and a validator's already-rendered text. Each is
translated in the domain its own first segment names — translating them all in dataflow printed a
consumer's keys raw on the page, which is what v1.3.2 fixes. If you render the report yourself, do
the same.
⚠️ A dry run is not a rollback. Nothing is persisted, so no trigger fires and no sequence advances — and a constraint only the database knows about is not caught. What it does catch is every row the mapper or the validator would reject.
Updating instead of skipping
use Jul6Art\DataflowBundle\Import\Spec\DuplicatePolicy; use Jul6Art\DataflowBundle\Import\Spec\ImportSpec; $spec = new ImportSpec($path, $inspection->mapping, onDuplicate: DuplicatePolicy::Update); $report = $runner->run($spec, $mapper, $reader, $resolver); $report->updated(); // rows that overwrote an existing record, disjoint from imported()
DuplicatePolicy has three cases: Skip (the default — count it, move on), Fail (turn it into a
row error, for a file that is supposed to contain only new records), and Update.
⚠️ Update requires a resolver. Without one, the runner has no way to find what a row would
update, and $existing would always be null — indistinguishable from every row being new.
ImportRunner::run() refuses the combination with an \InvalidArgumentException rather than
silently behaving like Skip with nothing ever skipped: a policy nobody chose, worse than one that
fails loudly.
⚠️ Your mapper MUST mutate and return the SAME object it is given, never a new one. When
$existing is non-null, it is the record findExisting() returned — already managed by Doctrine.
The runner never calls persist() on it, so a fresh object handed back in its place is silently
discarded, not saved:
public function map(array $row, ?object $existing = null): Customer { $customer = $existing ?? new Customer(); $customer->name = $row['name']; return $customer; }
A downloadable, complete error list
ImportReport::errors() keeps only the first ImportReport::MAX_RETAINED_ERRORS — a screen was
never meant to render fifty thousand rows. For the COMPLETE list, give the runner a sink instead of
reading it back from the report:
use Jul6Art\DataflowBundle\Import\Sink\CsvErrorSink; $handle = fopen('php://temp', 'w+'); $sink = new CsvErrorSink($handle); $report = $runner->run($spec, $mapper, $reader, $resolver, errorSink: $sink); if ($report->errorCount() > 0) { rewind($handle); // stream $handle back as a download — every error, not the report's hundred-row sample } fclose($handle);
⚠️ A sink is written to, one row at a time, as ImportRunner finds each error — never asked for
the accumulated list. That is what keeps a run with fifty thousand errors from costing fifty
thousand entries of PHP memory: CsvErrorSink calls fputcsv on your handle immediately, on the
same dialect and through the same FormulaInjectionGuard a TabularWriterInterface would use.
⚠️ This is not a TabularWriterInterface, on purpose. A writer's contract pulls a complete
iterable in one call; an import's errors are pushed, one at a time, from inside a run that has not
finished. The handle is yours to open and close — a sink that owned its own temporary file would
still have to hand it back for the download that follows.
A template file, so the mapping screen is optional
use Jul6Art\DataflowBundle\Import\TemplateBuilder; use Jul6Art\DataflowBundle\Io\Http\TabularResponseFactory; $builder = new TemplateBuilder(); return $this->responses->stream( $writer, $builder->header($mapper), $builder->rows($mapper), TabularResponseFactory::basename(['template', 'customers']), );
The columns are exactly RowMapperInterface::fields(), in the same order a mapping screen would
offer them — a user who fills in the template lands on the same columns as a user who fills in a
blank screen. One example row is included, filled in wherever the mapper has something to say.
⚠️ A field with a closed set of accepted values names them in its OWN HEADER TEXT —
status (active/inactive) — rather than a second sheet, a cell comment, or a dropdown: OpenSpout
exposes neither of the last two, and a header a user can read in a text editor, in Excel, or in any
other spreadsheet app is a plainer answer than a mechanism two of this bundle's three consumers
could not render anyway.
For a mapper that implements only RowMapperInterface, header() and rows() still return
something usable — the columns, and one blank row. Implement TemplatableRowMapperInterface to add
the example values and the enumerations; nothing else about the mapper changes.
A spreadsheet uploaded instead of a CSV
CsvReader refuses a binary workbook by name — dataflow.import.error.binary_spreadsheet —
instead of reading its bytes as text. XlsxReader reads a real .xlsx; see "A real spreadsheet,
and choosing a reader by content" above for how the two are told apart and resolved by content.
⚠️ Detection is by CONTENT, never by MIME type: a browser sends application/vnd.ms-excel for a
CSV saved out of Excel, so a MIME allow list that admits it admits real .xls workbooks too.
Running an import off the request thread
ImportRunner::run() already needs no Request and no session — a report scheduler could always
call it. ImportMessage + ImportMessageHandler are what let symfony/messenger do exactly that:
use Jul6Art\DataflowBundle\Import\Async\ImportMessage; $bus->dispatch(new ImportMessage( importId: (string) Uuid::v4(), // yours: the key ImportProgressStoreInterface keys on filePath: $uploadedPath, mapping: $inspection->mapping, mapperId: 'customer-import', // yours: what ImportMapperFactoryInterface turns back into a mapper context: ['accountId' => $account->getId()], ));
⚠️ The message carries IDENTIFIERS, not objects. A real transport serialises a message to text;
a RowMapperInterface holding an EntityManager or a tenant entity does not survive that, nor
should it — the worker gets a fresh EntityManager of its own. context is plain scalars only
(an account id, most often), and ImportMapperFactoryInterface — yours to implement, deliberately
not aliased to a default — turns mapperId + context back into a real mapper inside the worker:
final readonly class MyMapperFactory implements ImportMapperFactoryInterface { public function __construct(private EntityManagerInterface $em) {} public function mapper(string $mapperId, array $context): RowMapperInterface { return match ($mapperId) { 'customer-import' => new CustomerRowMapper($this->em->getReference(Account::class, $context['accountId'])), default => throw new \InvalidArgumentException($mapperId), }; } public function resolver(?string $resolverId, array $context): ?DuplicateResolverInterface { return null; } }
⚠️ symfony/messenger is not required by this bundle. ImportMessageHandler is removed by
AsyncImportPass when the application has not configured a bus — the same way three services are
removed when there is no EntityManagerInterface. Nothing breaks either way; async import is simply
absent until both are true.
⚠️ Progress goes through ImportProgressStoreInterface, bound to PassthroughNumberFormatter's
counterpart, NullImportProgressStore, by default. The request that dispatched the message has
already returned by the time the worker picks it up — bind your own to give a screen something to
poll:
services: Jul6Art\DataflowBundle\Import\Async\ImportProgressStoreInterface: '@App\Import\DoctrineImportProgressStore'
The screens
The bundle ships the body of the report builder and of an import's mapping and result panels — not the pages. A page carries a layout, a title, a menu entry, a permission check and a breadcrumb, every one of which is the application's.
1. Register the controller and the stylesheet
// assets/bootstrap.js — or however your build registers controllers import ReportBuilder from '@jul6art/dataflow-bundle/controllers/report_builder_controller'; app.register('dataflow--report-builder', ReportBuilder);
⚠️ If your build derives identifiers from a PATH instead — @symfony/stimulus-bundle and
startStimulusApp() do — name the relay file with a DASH. The derivation replaces / with --
and strips _controller.js; it does not turn underscores into dashes. So
assets/controllers/dataflow/report_builder_controller.js registers
dataflow--report_builder, while this bundle's partial emits dataflow--report-builder. The
controller then loads, registers, and attaches to nothing: no console error, no 404, inert buttons,
and a green test suite — the third consumer of this bundle lost an hour to it. Either name the file
report-builder_controller.js, or set stimulus_identifier to whatever your build actually
produces. A test worth having reads the dataflow.stimulus_identifier parameter and asserts a relay
of that name exists.
@import '@jul6art/dataflow-bundle/styles/dataflow.css';
⚠️ Add this bundle's
assets/to Tailwind'scontent. A class used only in the bundle's JavaScript is otherwise purged from the production stylesheet — and only from that one, which is the worst place to find out.
⚠️ The stylesheet uses Tailwind's default palette only — sky is its accent. A bundle cannot
apply bg-primary-50: that name exists in one application's theme and nowhere else, and Tailwind
does not warn — it fails your build with "The bg-primary-50 class does not exist", pointing
at a file in vendor/ you did not write. (v1.1.0 shipped exactly that.) If you have a brand colour,
re-declare the three rules marked ACCENT after the import:
@import '@jul6art/dataflow-bundle/styles/dataflow.css'; .step-tab.is-current { @apply bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700 text-primary-700 dark:text-primary-300; } .step-tab.is-current .step-tab-bullet, .step-tab.is-done .step-tab-bullet { @apply bg-primary-500 text-white; } .dataflow-modal-icon { @apply bg-accent-100 dark:bg-accent-900/40 rounded-full p-2.5 flex-shrink-0 text-xl text-accent-600; }
The markup also uses jul6art/ui-bundle's utility classes: form-panel, form-section-title,
form-fieldset, form-control, btn-primary, btn-secondary, panel.
2. Hand the controller your translator
// assets/app.js import { trans } from './translator'; import { registerTranslator } from '@jul6art/core-bundle/i18n/registry'; registerTranslator((key, parameters) => trans(key, parameters, 'javascript'));
⚠️ A controller shipped inside vendor/ cannot import your assets/translator.js — the
relative path out of vendor/ does not exist, and hard-coding one would tie the bundle to one
application's layout. So the application hands its translator over once, at boot, and every bundle
reads through the registry.
⚠️ Never through an HTML attribute. Posting a translation tree into data-…-translations-value
is how labels used to reach JavaScript here; it measured 8.7 kB of escaped HTML per page and was
removed.
3. Include the partial
{{ include('@Dataflow/report/_builder.html.twig', {
entities: report_entities,
fields_url: path('app_report_builder_fields'),
run_url: path('app_report_builder_run'),
export_url: path('app_report_builder_export'),
save_url: path('app_report_builder_save'),
load_url: path('app_report_builder_load', {id: '__ID__'})|replace({__ID__: '{id}'}),
can_share: is_granted('report:builder:share'),
}) }}
⚠️ Every endpoint is a parameter, and load_url must contain the literal {id}. The
implementation this is extracted from hard-coded four URLs, so the controller could only ever live
in one application mounted under one prefix — and it patched an id into the fifth with a regular
expression, because the route had been generated with id: 0.
⚠️ can_share is a boolean you compute. The bundle must not call is_granted() with a
permission code it invented: the codes are yours, and a guessed one answers false everywhere, which
hides the control on every screen and looks like a broken feature. Hiding it is not the guard
either — the payload carries shareScope, so the server has to refuse it too.
⚠️ The export step warns about the row ceiling on its own, in every project that includes this
partial. The number comes from a Twig global (dataflow_export_row_limit), set from the SAME
dataflow.limits.export_rows configuration ReportRunner enforces — nothing to pass, and nothing
that can drift from the real ceiling the way a hard-coded number in your own template could.
What the five endpoints exchange
The shipped controller posts multipart/form-data and reads JSON. Getting a field name wrong here
fails silently, so the wire is worth writing down:
| Endpoint | It sends | It reads back |
|---|---|---|
fields_url |
?entity=<FQCN> on a GET |
{fields: [ReportField::toArray(), …]} |
run_url |
entity, columns, filters (the last two JSON-encoded), optional limit/offset |
{columns: [{label, path}], rows: [...]} |
export_url |
the same, plus format (a writer's code()) |
the file itself |
save_url |
the same, plus name, shareScope, and id when updating |
{id: <the stored id>} |
load_url |
nothing — the id is in the path | {id, name, entity, columns, filters, shareScope} |
⚠️ shareScope is the string private or organization, in BOTH directions. It is not a
boolean, and the name is inherited from the first consumer, which had three scopes. An application
storing a boolean converts at those two points and nowhere else — and reading shared off the
request instead makes every save private, with no error, because an absent boolean is false.
⚠️ The auto-load entry point is ?load=<id> on the page itself. The controller reads it at
connect and fetches load_url, which is how a saved-reports table hands a report over — and it
makes the link shareable. The bundle ships no list of saved reports: that is a screen, and screens
are the application's.
⚠️ The controller reads only id from the save response. Anything else you return is dead
payload.
⚠️ save carries a CSRF token; run and export do not need one. They leak nothing across
origins — a cross-site POST cannot read a JSON body or a download — so what a forged request could
actually abuse is save: create a report in the victim's account, or overwrite one by guessing a
sequential id. The shipped partial mints the token itself, via the dataflow_csrf_token() Twig
function, into data-{{ stimulus }}-save-csrf-value; the shipped Stimulus controller reads that
attribute and posts it back as _dataflow_csrf_token. There is nothing to wire on your side beyond
having symfony/security-csrf installed and configured — without it, dataflow_csrf_token() mints
an empty string and ReportBuilderCsrfChecker::isValid() accepts every request, the same trade
datatable-bundle's own preferences endpoint makes.
Your controller has to check it — the bundle validates nothing on its own, since it ships no routes, only Twig partials and a Stimulus controller:
public function save(Request $request, ReportBuilderCsrfChecker $csrf): JsonResponse { if (!$csrf->isValid($request)) { throw new AccessDeniedHttpException('invalid_csrf_token'); } // … }
dataflow.csrf_token_id (default dataflow_report_builder) names the token id both sides validate
against. An application that already mints a token under another id for this screen can point the
bundle at it instead of minting a second one.
4. Wire the busy state, if you have an overlay
document.addEventListener('dataflow:busy', (e) => { /* your loader, on e.detail.element */ }); document.addEventListener('dataflow:idle', (e) => { /* take it down */ });
⚠️ The controller dispatches events rather than calling a loader mixin. The version it replaces
imported one from datatable-bundle — which would have made a report builder depend on a datatable
library for a spinner. The application decides what busy looks like; the controller only says
when it is.
The import panels
{{ include('@Dataflow/import/_mapper.html.twig', {inspection: inspection, fields: mapper.fields()}) }}
{{ include('@Dataflow/import/_report.html.twig', {report: report}) }}
The mapper emits no <form>, no CSRF token and no submit button: the action, the token name and the
route are yours.
⚠️ Its fields are named mapping[<index>], by column index. A name-keyed mapping loses one of
two columns called email, and the import then reads a plausible wrong column for every row.
Translation keys
Every key starts with dataflow. and the bundle ships the English catalogue. Two families are read
through a variable and so are invisible to a scanner — the twelve filter operator labels and the
four step labels — and DeclaredTranslationKeys names them for your guard:
protected static function declaredKeys(): array // the BROWSER's catalogue { return static::getContainer()->get(DeclaredTranslationKeys::class)->keys(); }
⚠️ keys() is what the browser reads; templateKeys() is what the server renders. They are
different catalogues — this ecosystem exposes exactly one domain to JavaScript, so a key the browser
needs is moved into it — and one list conflated them until v1.3.0: the first consumer's JavaScript
guard reported the four step labels as missing from its browser catalogue, which they legitimately
are. They come from a Twig partial.
⚠️ And point your JavaScript guard at this bundle's assets/ too. The controller lives in
vendor/, so a guard that scans only the project's own assets/ sees forty dataflow.* keys
translated by the project and read by nothing — and reports them dead.
⚠️ A ternary goes outside the lookup, never inside it. A condition in the argument position hides both keys from a scanner, so a catalogue clean-up deletes entries the screen renders. The screen this is extracted from displayed thirty-four raw keys across its whole surface, in production, with every test green — because each half was only ever asserted against itself.
Quality assurance
composer qa # cs-check + rector-check + phpstan (level max) + phpunit
Run composer qa, not the single tool you have in mind: the CI's "Coding standards" job runs
Rector too, and its lowest deps job installs the minimum of every constraint — which is where
this ecosystem has repeatedly found what a local run could not.
extra.symfony.require states which Symfony line this bundle targets; the CI enforces it with
SYMFONY_REQUIRE on both the highest and the lowest job. A local composer install may still
resolve a newer Symfony, which broadens what you exercise rather than narrowing it — but it means
the toolchain can propose something that only makes sense on one branch. rector.php skips one
such rule already, with the reason written next to it.
Whatever you do, keep the code free of classes that exist on only one of the declared branches.
A bundle promising ^7.4 || ^8.0 has to hold both.
License
This bundle is open-sourced software licensed under the MIT license.
© 2026 Jul6Art
