ecourty/doctrine-export-bundle
A flexible and extensible Symfony bundle for exporting Doctrine entities to various formats (CSV, JSON, XML).
Compatible with PHP 8.3+, Symfony 7.x/8.x, and Doctrine ORM 3.x/4.x 🎉
composer require ecourty/doctrine-export-bundle
If you're not using Symfony Flex, enable the bundle manually:
// config/bundles.php
return [
// ...
Ecourty\DoctrineExportBundle\DoctrineExportBundle::class => ['all' => true],
];
Export entities in a few lines:
use Ecourty\DoctrineExportBundle\Contract\DoctrineExporterInterface;
use Ecourty\DoctrineExportBundle\Enum\ExportFormat;
// Inject the service
public function __construct(
private DoctrineExporterInterface $exporter
) {}
// Export to CSV
$this->exporter->exportToFile(
entityClass: User::class,
format: ExportFormat::CSV,
filePath: '/tmp/users.csv'
);
// Stream to browser
return new StreamedResponse(
$this->exporter->exportToGenerator(User::class, ExportFormat::JSON),
Response::HTTP_OK,
['Content-Type' => 'application/json']
);
That's it! 🚀
Benchmarked with realistic dataset (10,000 entities, 15 fields):
| Format | Time (10k entities) | Memory Usage | Throughput |
|---|---|---|---|
| CSV | 0.274s | 2.00 MB | 36,496 ent/s |
| JSON | 0.238s | < 0.1 MB | 42,017 ent/s |
| XML | 0.314s | < 0.1 MB | 31,847 ent/s |
| Generator | 0.305s | < 0.1 MB | 32,787 ent/s |
Tested: 10,000 entities × 15 fields = 150,000 data points
Linear scaling: tested with 100,000 entities in ~2.5s with < 5 MB memory 🚀
use Ecourty\DoctrineExportBundle\Contract\DoctrineExporterInterface;
use Ecourty\DoctrineExportBundle\Enum\ExportFormat;
use App\Entity\User;
class UserExportService
{
public function __construct(
private DoctrineExporterInterface $exporter
) {}
public function exportActiveUsers(): void
{
$this->exporter->exportToFile(
entityClass: User::class,
format: ExportFormat::CSV,
filePath: '/tmp/active_users.csv',
criteria: ['isActive' => true],
limit: 1000,
orderBy: ['createdAt' => 'DESC']
);
}
}
Simply pass the generator to a StreamedResponse:
use Symfony\Component\HttpFoundation\StreamedResponse;
#[Route('/export/users')]
public function export(DoctrineExporterInterface $exporter): StreamedResponse
{
$format = ExportFormat::CSV;
return new StreamedResponse(
$exporter->exportToGenerator(
entityClass: User::class,
format: $format,
criteria: ['isActive' => true]
),
200,
[
'Content-Type' => $format->getMimeType(),
'Content-Disposition' => 'attachment; filename="users.' . $format->getExtension() . '"'
]
);
}
The bundle dispatches events before and after each export, allowing you to hook into the export lifecycle for logging, monitoring, or custom logic.
Events are optional - if no event dispatcher is configured, exports work normally without events.
The bundle uses the PSR-14 EventDispatcherInterface (Psr\EventDispatcher\EventDispatcherInterface), making it compatible with any PSR-14 compliant event dispatcher, not just Symfony's.
PreExportEvent - Dispatched before export beginsPostExportEvent - Dispatched after export completes (includes count and duration)use Ecourty\DoctrineExportBundle\Event\PostExportEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener]
public function onPostExport(PostExportEvent $event): void
{
$this->logger->info('Export completed', [
'entity' => $event->getEntityClass(),
'count' => $event->getExportedCount(),
]);
}
use Ecourty\DoctrineExportBundle\Event\PostExportEvent;
#[AsEventListener]
public function onPostExport(PostExportEvent $event): void
{
$duration = $event->getDurationInSeconds();
$throughput = $event->getExportedCount() / $duration;
$this->metrics->gauge('export.duration', $duration);
$this->metrics->gauge('export.throughput', $throughput);
}
PreExportEvent:
getEntityClass() - Entity class being exportedgetFormat() - Export format (CSV, JSON, XML)getCriteria() - Filter criteriagetLimit() - Result limitgetOffset() - Result offsetgetOrderBy() - Sort ordergetFields() - Selected fieldsgetOptions() - Export optionsPostExportEvent:
getExportedCount() - Number of entities exportedgetDurationInSeconds() - Export duration measured with microsecond precision (float)| Format | Extension | Description | Use Case |
|---|---|---|---|
| CSV | .csv |
Comma-separated values | Spreadsheets, Excel |
| JSON | .json |
JSON format | APIs |
| XML | .xml |
XML with configurable structure | Legacy enterprise systems |
// CSV
$exporter->exportToFile(User::class, ExportFormat::CSV, '/tmp/users.csv');
// JSON
$exporter->exportToFile(User::class, ExportFormat::JSON, '/tmp/users.json');
// XML
$exporter->exportToFile(User::class, ExportFormat::XML, '/tmp/users.xml');
Implement custom data transformations with entity processors. They allow you to modify exported data, add virtual fields, or apply business logic during export.
use Ecourty\DoctrineExportBundle\Contract\EntityProcessorInterface;
class EmailMaskingProcessor implements EntityProcessorInterface
{
public function process(object $entity, array $data, array $options): array
{
// Mask email addresses
if (isset($data['email'])) {
$data['email'] = preg_replace('/(?<=.).(?=.*@)/', '*', $data['email']);
}
return $data;
}
}
$exporter->exportToFile(
entityClass: User::class,
format: ExportFormat::CSV,
filePath: '/tmp/users.csv',
processors: [new EmailMaskingProcessor()]
);
class UserVirtualFieldsProcessor implements EntityProcessorInterface
{
public function process(object $entity, array $data, array $options): array
{
assert($entity instanceof User);
// Add computed fields
$data['displayName'] = $entity->getFirstName() . ' ' . $entity->getLastName();
$data['ageCategory'] = $entity->getAge() >= 30 ? 'senior' : 'junior';
return $data;
}
}
// Export with virtual fields
$exporter->exportToFile(
entityClass: User::class,
format: ExportFormat::JSON,
filePath: '/tmp/users.json',
fields: ['firstName', 'displayName', 'ageCategory'], // Include virtual fields
processors: [new UserVirtualFieldsProcessor()]
);
Processors are executed in order, allowing you to compose transformations:
$exporter->exportToFile(
entityClass: User::class,
format: ExportFormat::CSV,
filePath: '/tmp/users.csv',
processors: [
new EmailMaskingProcessor(), // First: mask emails
new UppercaseProcessor(), // Then: uppercase all strings
new UserVirtualFieldsProcessor(), // Finally: add virtual fields
]
);
When using a fully custom processor that handles all data extraction, disable the default processor for better performance:
class FullyCustomProcessor implements EntityProcessorInterface
{
public function process(object $entity, array $data, array $options): array
{
// Handle ALL field extraction yourself
$data['id'] = $entity->getId();
$data['email'] = $entity->getEmail();
// ... handle all fields
return $data;
}
}
$exporter->exportToFile(
entityClass: User::class,
format: ExportFormat::JSON,
filePath: '/tmp/users.json',
fields: ['id', 'email'],
options: [
// Skip default processor - custom processor handles everything
DoctrineExporterInterface::OPTION_DISABLE_DEFAULT_PROCESSOR => true,
],
processors: [new FullyCustomProcessor()]
);
Note: The default processor handles property access, associations, and data normalization. Only disable it when your custom processor fully replaces this functionality.
You can specify which fields to export. If not specified, all entity fields are exported:
// Export only specific fields
$exporter->exportToFile(
entityClass: User::class,
format: ExportFormat::CSV,
filePath: '/tmp/users_minimal.csv',
fields: ['id', 'email', 'firstName', 'lastName'] // Only these fields
);
// Export all fields (default)
$exporter->exportToFile(
entityClass: User::class,
format: ExportFormat::CSV,
filePath: '/tmp/users_full.csv',
fields: [] // Empty = all fields
);
Note: Field names are validated against the entity metadata. If you specify a field that doesn't exist, an InvalidCriteriaException will be thrown.
use Ecourty\DoctrineExportBundle\Contract\DoctrineExporterInterface;
$exporter->exportToFile(
entityClass: User::class,
format: ExportFormat::CSV,
filePath: '/tmp/users.csv',
options: [
// Boolean values as integers (default: true)
DoctrineExporterInterface::OPTION_BOOLEAN_TO_INTEGER => false,
// Custom datetime format (default: ATOM)
DoctrineExporterInterface::OPTION_DATETIME_FORMAT => 'Y-m-d H:i:s',
// Custom null value representation (default: null)
DoctrineExporterInterface::OPTION_NULL_VALUE => '',
// Strict field validation - throw exception if field doesn't exist (default: false)
DoctrineExporterInterface::OPTION_STRICT_FIELDS => true,
// Disable default processor for performance (default: false)
// Only use when custom processor handles all data extraction
DoctrineExporterInterface::OPTION_DISABLE_DEFAULT_PROCESSOR => true,
]
);
| Option | Type | Default | Description |
|---|---|---|---|
OPTION_BOOLEAN_TO_INTEGER |
bool |
true |
Convert boolean values to integers (1/0) instead of strings ('true'/'false') |
OPTION_DATETIME_FORMAT |
string |
DateTimeInterface::ATOM |
PHP date format for DateTime fields (e.g., 'Y-m-d H:i:s', 'c') |
OPTION_NULL_VALUE |
string|int|float |
null |
Custom representation for null values (e.g., 'NULL', 'N/A', '') |
OPTION_STRICT_FIELDS |
bool |
false |
Throw exception if a requested field doesn't exist on the entity |
OPTION_DISABLE_DEFAULT_PROCESSOR |
bool |
false |
Skip default processor when using custom processors that handle all processing |
The bundle automatically validates that fields in criteria and orderBy exist in the entity. If an invalid field is specified, an InvalidCriteriaException will be thrown with a helpful error message listing all available fields.
// This will throw InvalidCriteriaException if 'nonExistentField' doesn't exist
$exporter->exportToFile(
entityClass: User::class,
format: ExportFormat::CSV,
filePath: '/tmp/users.csv',
criteria: ['nonExistentField' => 'value'] // ❌ Throws exception
);
The bundle uses streaming and entity detachment for automatic memory efficiency:
// Streaming export - automatically memory efficient
foreach ($exporter->exportToGenerator(User::class, ExportFormat::CSV) as $line) {
echo $line; // Each entity is processed and immediately detached
flush();
}
How it works:
toIterable() for true streaming (one entity at a time)clear(), no flush(), no batch management neededWhy no batching? This bundle doesn't need batch processing because:
Doctrine associations are automatically exported as primary keys:
// Given entities:
// Article (id, title, author_id) -> ManyToOne -> User (id, name)
// Article (id, title) -> ManyToMany -> Tag (id, name)
$exporter->exportToFile(
entityClass: Article::class,
format: ExportFormat::JSON,
filePath: '/tmp/articles.json',
fields: ['title', 'author', 'tags']
);
// Output:
// [
// {"title": "Article 1", "author": 42, "tags": [1, 2, 3]},
// {"title": "Article 2", "author": 43, "tags": [2, 4]}
// ]
How it works:
[1, 2, 3]null[]Benefits:
Run all quality checks:
composer qa
Individual commands:
# Code style check
composer cs-check
# Fix code style
composer cs-fix
# Static analysis (PHPStan level 9)
composer phpstan
# Run tests
composer test
# Run all tests (including performance)
composer test:all
# Run only performance tests
composer test:performance
MIT
How can I help you explore Laravel packages today?