{"id":17865776,"url":"https://github.com/ttskch/bulkony","last_synced_at":"2025-10-07T17:43:09.061Z","repository":{"id":42085705,"uuid":"327847556","full_name":"ttskch/bulkony","owner":"ttskch","description":"Easy and flexible CSV exports and imports in PHP ⚡","archived":false,"fork":false,"pushed_at":"2023-08-17T14:23:46.000Z","size":48,"stargazers_count":9,"open_issues_count":0,"forks_count":1,"subscribers_count":4,"default_branch":"main","last_synced_at":"2024-09-20T10:08:56.243Z","etag":null,"topics":["bulk","csv","csv-export","csv-import","excel","php"],"latest_commit_sha":null,"homepage":"","language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ttskch.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2021-01-08T08:49:02.000Z","updated_at":"2022-08-01T11:27:34.000Z","dependencies_parsed_at":"2022-09-03T00:11:16.790Z","dependency_job_id":null,"html_url":"https://github.com/ttskch/bulkony","commit_stats":null,"previous_names":[],"tags_count":13,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ttskch%2Fbulkony","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ttskch%2Fbulkony/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ttskch%2Fbulkony/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ttskch%2Fbulkony/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ttskch","download_url":"https://codeload.github.com/ttskch/bulkony/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":221811464,"owners_count":16884335,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["bulk","csv","csv-export","csv-import","excel","php"],"created_at":"2024-10-28T09:24:55.945Z","updated_at":"2025-10-07T17:43:08.933Z","avatar_url":"https://github.com/ttskch.png","language":"PHP","readme":"# bulkony\n\n[![](https://github.com/ttskch/bulkony/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/ttskch/bulkony/actions/workflows/ci.yaml?query=branch:main)\n[![codecov](https://codecov.io/gh/ttskch/bulkony/graph/badge.svg?token=zwZHUbGrHp)](https://codecov.io/gh/ttskch/bulkony)\n[![Packagist Version](https://img.shields.io/packagist/v/ttskch/bulkony?style=flat-square)](https://packagist.org/packages/ttskch/bulkony)\n[![Packagist Downloads](https://img.shields.io/packagist/dm/ttskch/bulkony?style=flat-square)](https://packagist.org/packages/ttskch/bulkony)\n\nEasy and flexible CSV exports and imports in PHP ⚡\n\n```php\nuse Ttskch\\Bulkony\\Import\\Importer;\n\n$importer = new Importer();\n$rowVisitor = new App\\ValidatableRowVisitor();\n\n$importer-\u003eimport('/path/to/input.csv', $rowVisitor);\n\nif ($importer-\u003egetErrorListCollection()-\u003eisEmpty()) {\n    echo \"Successfully imported!\\n\";\n}\n```\n\n## TOC\n\n\u003cdetails\u003e\n\n* [Features](#features)\n* [Requirements](#requirements)\n* [Installation](#installation)\n* [Usage](#usage)\n    * [Export](#export)\n        * [Export to file](#export-to-file)\n        * [Send HTTP response in WAF way](#send-http-response-in-waf-way)\n            * [Symfony](#symfony)\n            * [Laravel](#laravel)\n            * [CakePHP](#cakephp)\n    * [Import](#import)\n        * [With validation](#with-validation)\n        * [With previewing feature](#with-previewing-feature)\n        * [With preprocessing](#with-preprocessing)\n* [Getting involved](#getting-involved)\n\n\u003c/details\u003e\n\n## Features\n\n* Multibyte support\n* MS Excel friendly (exports as UTF-8 CSV with BOM)\n* Memory efficient (unless you import non UTF-8 CSV)\n* Easy to validate row by row\n* Easy to implement preview feature, that shows which cell will be changed after importing\n\n## Requirements\n\n* PHP \u003e= 7.4\n* ext-mbstring\n\n## Installation\n\n```shell\n$ composer require ttskch/bulkony\n```\n\n## Usage\n\n### Export\n\n```php\nuse Ttskch\\Bulkony\\Export\\Exporter;\n\n$exporter = new Exporter();\n$rowGenerator = new App\\UserRowGenerator();\n\n$exporter-\u003eexportAndOutput('users.csv', $rowGenerator); // send HTTP response for downloading\n```\n\n```php\nnamespace App;\n\nuse Ttskch\\Bulkony\\Export\\RowGenerator\\RowGeneratorInterface;\n\nclass UserRowGenerator implements RowGeneratorInterface\n{\n    public function __construct(private $userRepository)\n    {\n    }\n\n    public function getHeadingRows(): array\n    {\n        // return 2D array so that you can export multiple header rows\n        return [['id', 'name', 'email']];\n    }\n\n    public function getBodyRowsIterator(): iterable\n    {\n        while ($user = $this-\u003euserRepository-\u003efindNext()) {\n            // yield 2D array so that you can export multiple rows for one data\n            yield [\n                [$user-\u003egetId(), $user-\u003egetName(), $user-\u003egetEmail()],\n            ];\n        }\n    }\n}\n```\n\n#### Export to file\n\n```php\nuse Ttskch\\Bulkony\\Export\\Exporter;\n\n$exporter = new Exporter();\n$rowGenerator = new App\\UserRowGenerator();\n\n$exporter-\u003eexport('/path/to/output.csv', $rowGenerator);\n```\n\n#### Send HTTP response in WAF way\n\n##### Symfony\n\n```php\n$response = new StreamedResponse();\n$response-\u003eheaders-\u003eset('Content-Type', 'text/csv');\n$response-\u003eheaders-\u003eset('Content-Disposition', HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, 'users.csv'));\n$response-\u003esetCallback(function () use ($exporter, $rowGenerator) {\n    $exporter-\u003eexport('php://output', $rowGenerator);\n});\n\nreturn $response-\u003esend();\n```\n\n##### Laravel\n\n```php\nreturn response()\n    -\u003eheader('Content-Type', 'text/csv')\n    -\u003estreamDownload(function () use ($exporter, $rowGenerator) {\n        $exporter-\u003eexport('php://output', $rowGenerator);\n    }, 'users.csv');\n```\n\n##### CakePHP\n\n```php\n$stream = new CallbackStream(function () use ($exporter, $rowGenerator) {\n    $exporter-\u003eexport('php://output', $rowGenerator);\n});\n\nreturn $response\n    -\u003ewithType('csv')\n    -\u003ewithDownload('users.csv')\n    -\u003ewithBody($stream);\n```\n\n### Import\n\n```php\nuse Ttskch\\Bulkony\\Import\\Importer;\n\n$importer = new Importer();\n$rowVisitor = new App\\UserRowVisitor();\n\n$importer-\u003eimport('/path/to/input.csv', $rowVisitor);\n```\n\n```php\nnamespace App;\n\nuse Ttskch\\Bulkony\\Import\\RowVisitor\\Context;\nuse Ttskch\\Bulkony\\Import\\RowVisitor\\RowVisitorInterface;\n\nclass UserRowVisitor implements RowVisitorInterface\n{\n    public function __constructor(private $userRepository)\n    {\n    }\n\n    public function import(array $csvRow, int $csvLineNumber, Context $context): void\n    {\n        $this-\u003euserRepository-\u003epersist($this-\u003ehydrate($csvRow));\n    }\n    \n    private function hydrate(array $csvRow): App\\User\n    {\n        // create App\\User instance from csv row in some way\n        return new App\\User($csvRow);\n    }\n}\n```\n\n#### With validation\n\n```php\nuse Ttskch\\Bulkony\\Import\\Importer;\n\n$importer = new Importer();\n$rowVisitor = new App\\UserRowVisitor();\n\n$importer-\u003eimport('/path/to/input.csv', $rowVisitor);\n\nif ($importer-\u003egetErrorListCollection()-\u003eisEmpty()) {\n    echo \"Successfully imported!\\n\";\n} else {\n    // you can access to validation errors by csv line number and column (heading) name\n    // in other words,\n    //   ErrorListCollection : errors in whole csv file\n    //   ErrorList           : errors in one csv row\n    //   Error               : errors in one csv cell (can contain multiple error messages)\n    foreach ($importer-\u003egetErrorListCollection() as $errorList) {\n        foreach ($errorList as $error) {\n            foreach ($error-\u003egetMessages() as $message) {\n                echo sprintf(\"Error: row %d col `%s`: %s\\n\", $errorList-\u003egetCsvLineNumber(), $error-\u003egetCsvHeading(), $message);\n            }\n        }\n    }\n}\n```\n\n```php\nnamespace App;\n\nuse Ttskch\\Bulkony\\Import\\RowVisitor\\Context;\nuse Ttskch\\Bulkony\\Import\\RowVisitor\\ValidatableRowVisitorInterface;\nuse Ttskch\\Bulkony\\Import\\Validation\\ErrorList;\n\nclass UserRowVisitor implements ValidatableRowVisitorInterface\n{\n    public function __constructor(private $userRepository, private $validator)\n    {\n    }\n\n    public function import(array $csvRow, int $csvLineNumber, Context $context): void\n    {\n        $this-\u003euserRepository-\u003epersist($this-\u003ehydrate($csvRow));\n    }\n\n    public function validate(array $csvRow, int $csvLineNumber, ErrorList $errorList, Context $context): void\n    {\n        $user = $this-\u003ehydrate($csvRow);\n\n        foreach ($this-\u003evalidator-\u003evalidate($user) as $validationError) {\n            // get csv heading name from validation error in some way\n            $csvHeading = $this-\u003egetCsvHeadingFromValidationError($validationError);\n\n            // upsert Error into ErrorList\n            $errorList-\u003eget($csvHeading, true)-\u003eaddMessage($validationError-\u003egetMessage());\n        }\n    }\n\n    public function onError(array $csvRow, int $csvLineNumber,  ErrorList $errorList, Context $context): bool\n    {\n        // you can log errors for one csv row or do something here...\n        \n        // you can choose continue or abort on error occurred\n        return ValidatableRowVisitorInterface::CONTINUE_ON_ERROR;\n        // return ValidatableRowVisitorInterface::ABORT_ON_ERROR;\n    }\n    \n    private function hydrate(array $csvRow): App\\User\n    {\n        // create App\\User instance from csv row in some way\n        return new App\\User($csvRow);\n    }\n}\n```\n\nIn this example, you may find that `$this-\u003ehydrate($csvRow)` is called twice in `validate()` and `import()`. Sometimes this is not good.\n\nIf cost of hydrating object from csv row is very high, you can pass the hydrated object through `Context` like below.\n\n```php\npublic function import(array $csvRow, int $csvLineNumber, Context $context): void\n{\n    // get hydrated $user\n    $user = $context['user'];\n    \n    $this-\u003euserRepository-\u003epersist($user);\n}\n\npublic function validate(array $csvRow, int $csvLineNumber, ErrorList $errorList, Context $context): void\n{\n    $user = $this-\u003ehydrate($csvRow);\n\n    // pass hydrated $user\n    $context['user'] = $user;    \n    \n    // validate $user ...\n}\n```\n\n#### With previewing feature\n\n```php\nuse Ttskch\\Bulkony\\Import\\Importer;\nuse Ttskch\\Bulkony\\Import\\Preview\\Preview;\n\n$importer = new Importer();\n$rowVisitor = new App\\UserRowVisitor();\n\n/** @var Preview $preview */\n$preview = $importer-\u003epreview('/path/to/input.csv', $rowVisitor);\n\n// $preview contains whole csv data and knows WHICH CELL WILL BE CHANGED after importing\nrender('some/template', [\n    'preview' =\u003e $preview,\n]);\n```\n\n```php\nnamespace App;\n\nuse Ttskch\\Bulkony\\Import\\Preview\\Row;\nuse Ttskch\\Bulkony\\Import\\RowVisitor\\Context;\nuse Ttskch\\Bulkony\\Import\\RowVisitor\\PreviewableRowVisitorInterface;\n\nclass UserRowVisitor implements PreviewableRowVisitorInterface\n{\n    // ...\n\n    public function preview(array $csvRow, int $csvLineNumber, Row $previewRow, Context $context): void\n    {\n        $originalUser = $this-\u003erepository-\u003efind($csvRow['id']);\n        $importedUser = $this-\u003ehydrate($csvRow);\n        \n        if ($originalUser-\u003ename !== $importedUser-\u003ename) {\n            $previewRow-\u003eget('name')-\u003esetChanged();\n        }\n\n        if ($originalUser-\u003eemail !== $importedUser-\u003eemail) {\n            $previewRow-\u003eget('email')-\u003esetChanged();\n        }\n    }\n}\n```\n\nOf course you can implement previewing feature with validation.\n\nIn this example, if `App\\UserRowVisitor` implements `ValidatableRowVisitorInterface`, `$preview` holds whole validation errors automatically. \n\n#### With preprocessing\n\nYou can preprocess all csv rows before importing and previewing and store some results in `Context`. \n\n```php\nnamespace App;\n\nuse Ttskch\\Bulkony\\Import\\Preview\\Row;\nuse Ttskch\\Bulkony\\Import\\RowVisitor\\Context;\nuse Ttskch\\Bulkony\\Import\\RowVisitor\\PreprocessableRowVisitorInterface;\nuse Ttskch\\Bulkony\\Import\\RowVisitor\\PreviewableRowVisitorInterface;\n\nclass UserRowVisitor implements PreprocessableRowVisitorInterface, PreviewableRowVisitorInterface\n{\n    // ...\n\n    public function preprocess(array $csvRow, int $csvLineNumber, Context $context): void\n    {\n        $originalUser = $this-\u003erepository-\u003efind($csvRow['id']);\n        $importedUser = $this-\u003ehydrate($csvRow);\n\n        $context[sprintf('originalUser%d', $csvLineNumber)] = $originalUser;\n        $context[sprintf('importedUser%d', $csvLineNumber)] = $importedUser;\n    }\n\n    public function import(array $csvRow, int $csvLineNumber, Context $context): void\n    {\n        $user = $context[sprintf('originalUser%d', $csvLineNumber)];\n        \n        $this-\u003euserRepository-\u003epersist($user);\n    }\n\n    public function preview(array $csvRow, int $csvLineNumber, Row $previewRow, Context $context): void\n    {\n        $originalUser = $context[sprintf('originalUser%d', $csvLineNumber)];\n        $importedUser = $context[sprintf('importedUser%d', $csvLineNumber)];\n        \n        if ($originalUser-\u003ename !== $importedUser-\u003ename) {\n            $previewRow-\u003eget('name')-\u003esetChanged();\n        }\n\n        if ($originalUser-\u003eemail !== $importedUser-\u003eemail) {\n            $previewRow-\u003eget('email')-\u003esetChanged();\n        }\n    }\n}\n```\n\nFor example, if the entity to be imported has a self-referential property and validation is required based on the member state of that property, it is necessary to pre-hydrate the entities corresponding to all rows in the CSV. This feature is useful in such cases.\n\n## Getting involved\n\n```shell\n$ composer install\n$ composer bin tools install\n\n# Develop...\n\n$ composer tests\n```\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fttskch%2Fbulkony","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fttskch%2Fbulkony","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fttskch%2Fbulkony/lists"}