tmonier / sylius-erp-flatfile-plugin
Flat-file exchange with an ERP for Sylius 2: orders exported as CSV files (documented contract, UTF-8, ";" separator, minor-unit integers) from the events of the webhook outbox, deposited atomically in an exchange directory
Package info
github.com/thibmonier/sylius-erp-flatfile-plugin
Type:sylius-plugin
pkg:composer/tmonier/sylius-erp-flatfile-plugin
Requires
- php: ^8.2
- sylius/sylius: ^2.2
- symfony/clock: ^6.4 || ^7.4 || ^8.0
- symfony/console: ^6.4 || ^7.4 || ^8.0
- tmonier/sylius-webhook-outbox-plugin: ^1.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpstan/phpstan-doctrine: ^2.0
- phpstan/phpstan-webmozart-assert: ^2.0
- phpunit/phpunit: ^10.5
- rector/rector: ^2.0
- sylius-labs/coding-standard: ^4.4
- sylius/test-application: ^2.0.0@alpha
- symfony/browser-kit: ^6.4 || ^7.4 || ^8.0
- symfony/css-selector: ^6.4 || ^7.4 || ^8.0
- symfony/debug-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/dom-crawler: ^6.4 || ^7.4 || ^8.0
- symfony/dotenv: ^6.4 || ^7.4 || ^8.0
- symfony/http-client: ^6.4 || ^7.4 || ^8.0
- symfony/intl: ^6.4 || ^7.4 || ^8.0
- symfony/runtime: ^6.4 || ^7.4 || ^8.0
- symfony/web-profiler-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/webpack-encore-bundle: ^2.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-25 06:39:57 UTC
README
Flat-file exchange with an ERP for Sylius 2. This first slice (version 0.x) exports the orders as CSV files, built from the events of tmonier/sylius-webhook-outbox-plugin, according to a documented contract, and deposits them atomically in an exchange directory that your ERP reads.
It is a neutral contract, not a connector. It connects to no ERP and names none: an ERP, or the integrator who connects it, reads the files from the directory. Importing prices and stock is a later slice.
What it does
- A console command takes the pending
order.placedevents of the outbox and writes one file per batch in the exchange directory. - The file is derived from the events, not from a second read of the shop tables: the outbox stays the single source of truth. The webhook outbox plugin is not modified.
- The file is written in the
.incomingsubdirectory then renamed: a reader of the exchange directory never sees a partial file. - Each event is exported once: the id of the event (the
webhook_idof the outbox) is the primary key of a journal table, so two exports running at the same time cannot write the same order twice. - If the directory is missing, not writable or full, no file is left behind, the error is logged with a clear message, the command fails and the events stay to export.
- Events erased for the GDPR by the outbox plugin (
anonymized_at) are left out.
Requirements
- PHP 8.2 or later
- Sylius 2.2 or 2.3 (Symfony 6.4, 7.4 or 8.0)
tmonier/sylius-webhook-outbox-plugin^1.0, installed and configured
Installation
composer require tmonier/sylius-erp-flatfile-plugin
Register the bundles in config/bundles.php (the outbox plugin first):
return [ // ... Tmonier\SyliusWebhookOutboxPlugin\TmonierSyliusWebhookOutboxPlugin::class => ['all' => true], Tmonier\SyliusErpFlatfilePlugin\TmonierSyliusErpFlatfilePlugin::class => ['all' => true], ];
Run the migration (the plugin registers its migrations by itself):
bin/console doctrine:migrations:migrate
Configuration
# config/packages/tmonier_sylius_erp_flatfile.yaml tmonier_sylius_erp_flatfile: # The directory your ERP reads. It must exist and be writable by the process that runs the export. exchange_directory: '%kernel.project_dir%/var/erp-exchange' # default # Maximum number of orders per file batch_size: 500 # default # Outbox event types that are exported event_types: ['order.placed'] # default
Run the export at the rhythm your ERP reads the directory (cron, Symfony Scheduler, a loop of your own):
bin/console tmonier:erp-flatfile:export-orders
One call writes one batch (at most batch_size orders). The command prints the file name, the number of orders and of lines, or Nothing to export.. Exit code 1 when the directory is unavailable.
Contract of the order file
| Property | Value |
|---|---|
| Name | orders-<UTC date and time>-<8 hexadecimal characters>.csv, for example orders-20261024T101500Z-3fa9c1d2.csv, unique per batch |
| Encoding | UTF-8, no byte order mark |
| Separator | ; ; a field that contains ;, " or a line break is enclosed in " (quotes doubled) |
| Lines | A header line, then one line per order line; each line ends with \n |
| Amounts | Integers in the minor unit of the currency (1250 is 12.50 EUR); no decimal point |
| Column | Content |
|---|---|
order_number |
Number of the order |
webhook_id |
Id of the outbox event (UUID); the idempotence key on the reader side |
placed_at |
Date of the end of the checkout, UTC, YYYY-MM-DDTHH:MM:SSZ |
currency |
Currency code of the order |
customer_email |
E-mail of the customer |
variant_code |
Code of the product variant |
quantity |
Quantity ordered |
unit_price_minor |
Unit price, minor unit |
line_total_minor |
Total of the line, minor unit |
ship_country |
Country code of the shipping address |
order_number;webhook_id;placed_at;currency;customer_email;variant_code;quantity;unit_price_minor;line_total_minor;ship_country
000000042;0192a000-0000-7000-8000-000000000001;2026-10-24T10:15:00Z;EUR;buyer@example.com;MUG-RED;2;1250;2500;FR
000000042;0192a000-0000-7000-8000-000000000001;2026-10-24T10:15:00Z;EUR;buyer@example.com;MUG-BLUE;1;990;990;FR
The columns do not carry the event type: with several types in event_types, the same order appears once per type (with a different webhook_id). Keep the default order.placed unless your reader handles it.
Reading the directory
Read the files whose name ends with .csv in the exchange directory; ignore the .incoming subdirectory. A file is complete as soon as it appears. Delete or move the files you have read: the plugin never purges the directory.
Guarantees and limits
- At least once. The journal and the file are tied in one database transaction, and the file is renamed before the commit. If the process dies in between (or the commit fails), the events stay to export and a later file repeats them: deduplicate on
webhook_idon the reader side. - Order. Events are taken by date, oldest first; a batch can end up in the directory after a later one only if you run several exports in parallel.
- Personal data. The files hold the e-mail of the customers and a country. The retention of the exchange directory is not decided by the plugin: it does not purge the files, and you set the retention that your processing record requires.
- Spreadsheets. The files are meant for a program. A spreadsheet that opens them may interpret a field that starts with
=,+,-or@as a formula; do not open them there without care. - The journal table (
tmonier_erp_flatfile_exported_event) has no foreign key to the outbox: purging the outbox does not touch it. - No ERP is named, tested or supported: the plugin only writes files.
Importing prices and stock
The plugin also imports two kinds of flat files from the exchange directory, prices-*.csv and stock-*.csv:
bin/console tmonier:erp-flatfile:import-batches
- Prices
variant_code;price_minor;currency;channel: one price per variant and channel (ChannelPricing, no customer group). All or nothing: one invalid line rejects the batch and nothing is written. The currency must be the base currency of the channel. - Stock
variant_code;on_hand: the quantity on hand, as an absolute value. The quantity on hold is never touched. Line by line: a valid line is applied, an invalid one is reported. A quantity below the quantity on hold is refused (below_on_hold). - Both: UTF-8 without byte order mark,
;separator, the header of the contract, integers, a line break at the end of the file (a file cut in the middle is refused). - A batch is its file name and the SHA-256 of its content. A batch already processed is ignored (
duplicate): nothing is written twice. The journal (tmonier_erp_flatfile_import_batch) keeps one line per batch, with its outcome and its report. - Once imported, a file moves to the
processedsubdirectory of the exchange directory (a rename, so atomic). A file of the same name already there is kept: the new one is prefixed with the start of its checksum. - Reasons in the report:
columns,duplicate,unknown_variant,channel,currency,price,on_hand,below_on_hold; for a whole file (line 0):empty,encoding,truncated,header.
Not done: no API, no retention (the journal and the processed files are kept until the operator removes them), no customer group prices, no reservation logic (the quantity on hold is only read).
Tests and quality
vendor/bin/phpunit --testsuite=unit
vendor/bin/phpunit --testsuite=non-unit # needs the database of the test application
vendor/bin/phpstan analyse
vendor/bin/ecs check
License
MIT.