Search by

jul6art / dataflow-bundle

jul6art

Tabular import, export and report engine for Symfony

Package info

github.com/jul6art/dataflow-bundle

Type:symfony-bundle

pkg:composer/jul6art/dataflow-bundle

Statistics

Installs: 205

Dependents: 1

Suggesters: 1

Stars: 3

Open Issues: 0

v1.15.0 2026-09-17 16:30 UTC

README

logo dev in the hood

Tabular import, export and report engine for Symfony

License Version

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 to jul6art/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 DateTimeInterfaceReportRunner 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 TEXTstatus (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's content. 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