diff --git a/.claude/settings.local.json b/.claude/settings.local.json
deleted file mode 100644
index 9bd7b52..0000000
--- a/.claude/settings.local.json
+++ /dev/null
@@ -1,43 +0,0 @@
-{
- "permissions": {
- "allow": [
- "Bash(composer install:*)",
- "Bash(./vendor/bin/phpunit:*)",
- "Bash(vendor/bin/phpunit:*)",
- "Bash(composer show:*)",
- "WebSearch",
- "WebFetch(domain:php.watch)",
- "Bash(composer test:*)",
- "Bash(git status:*)",
- "Bash(git reset:*)",
- "Bash(git add:*)",
- "Bash(git commit:*)",
- "Bash(git rm:*)",
- "Bash(git push:*)",
- "Bash(gh pr create:*)",
- "Bash(gh repo view:*)",
- "Bash(gh repo edit:*)",
- "Bash(composer validate:*)",
- "Bash(composer update:*)",
- "Bash(composer run-script:*)",
- "Bash(vendor/bin/phpstan analyse:*)",
- "Bash(git -C /Users/andrewlukin/Work/chamber-orchestra/pagination-bundle status)",
- "Bash(git -C /Users/andrewlukin/Work/chamber-orchestra/pagination-bundle diff)",
- "Bash(git -C /Users/andrewlukin/Work/chamber-orchestra/pagination-bundle log --oneline -5)",
- "Bash(git -C /Users/andrewlukin/Work/chamber-orchestra/pagination-bundle add .github/dependabot.yml .github/workflows/php.yml .gitignore README.md composer.json .php-cs-fixer.dist.php phpstan.neon phpstan-baseline.neon src/ tests/)",
- "Bash(git -C:*)",
- "Bash(git fetch:*)",
- "Bash(git merge:*)",
- "Bash(vendor/bin/php-cs-fixer fix:*)",
- "Bash(gh pr list:*)",
- "Bash(git checkout:*)",
- "Bash(git pull:*)",
- "Bash(composer analyse:*)",
- "Bash(git stash:*)",
- "Bash(composer cs-check:*)",
- "Bash(composer require:*)",
- "Bash(php -r:*)",
- "Bash(php vendor/bin/phpunit:*)"
- ]
- }
-}
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 9b9d4c6..b1becdc 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -28,6 +28,8 @@
'scope' => 'all',
'strict' => true,
],
+ 'nullable_type_declaration_for_default_null_value' => true,
+ 'nullable_type_declaration' => ['syntax' => 'question_mark'],
])
->setFinder($finder)
->setRiskyAllowed(true)
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..cb60b46
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,146 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+ChamberOrchestra File Bundle is a Symfony bundle that automatically handles file uploads for Doctrine ORM entities. It uses PHP attributes to mark uploadable fields, hooks into Doctrine lifecycle events to upload/inject/remove files transparently, and supports pluggable naming strategies and storage backends.
+
+## Build and Test Commands
+
+```bash
+# Install dependencies
+composer install
+
+# Run all tests
+./vendor/bin/phpunit
+
+# Run specific test file
+./vendor/bin/phpunit tests/Unit/Handler/HandlerTest.php
+
+# Run tests in specific directory
+./vendor/bin/phpunit tests/Unit/Storage/
+
+# Run single test method
+./vendor/bin/phpunit --filter testMethodName
+
+# Run static analysis (level max)
+composer run-script analyse
+
+# Check code style (dry-run)
+composer run-script cs-check
+
+# Auto-fix code style
+./vendor/bin/php-cs-fixer fix
+```
+
+## Architecture
+
+### Upload Attributes
+
+**Uploadable** (`src/Mapping/Attribute/Uploadable.php`): PHP attribute applied to entity classes to mark them as uploadable. Implements Doctrine's `MappingAttribute`. Validates inputs in constructor (prefix must not contain `..`, namingStrategy must implement `NamingStrategyInterface`). Options: `prefix` (upload subdirectory path), `namingStrategy` (class implementing `NamingStrategyInterface`, defaults to `HashingNamingStrategy`), `behaviour` (file removal policy: `Behaviour::Remove`, `Behaviour::Keep`, or `Behaviour::Archive`), `storage` (named storage to use, defaults to `'default'`; must match a name defined under `storages` in bundle config).
+
+**UploadableProperty** (`src/Mapping/Attribute/UploadableProperty.php`): PHP attribute applied to entity properties of type `File|null` to mark them as upload fields. Options: `mappedBy` (name of the string property that persists the relative file path).
+
+### Mapping Layer
+
+**UploadableDriver** (`src/Mapping/Driver/UploadableDriver.php`): Extends `AbstractMappingDriver` from metadata-bundle. Reads `#[Uploadable]` from the entity class and `#[UploadableProperty]` from properties, validates that the naming strategy implements `NamingStrategyInterface` and that `mappedBy` target properties exist, then builds bidirectional field mappings (`upload`/`mappedBy` on the file field, `inversedBy` on the path field). Supports Doctrine embeddables by recursively joining embedded configurations with dot-prefixed field names.
+
+**UploadableConfiguration** (`src/Mapping/Configuration/UploadableConfiguration.php`): Extends `AbstractMetadataConfiguration`. Stores prefix, behaviour, naming strategy class, and storage name. Provides `getUploadableFieldNames()` (fields with `#[UploadableProperty]`) and `getMappedByFieldNames()` (the corresponding persisted path fields). Implements `__serialize`/`__unserialize` for metadata caching.
+
+**Behaviour** (`src/Mapping/Helper/Behaviour.php`): Int-backed enum with cases `Keep` (0, leave orphan files on disk), `Remove` (1, delete file when entity is updated/deleted), and `Archive` (2, copy file to archive directory before removing from storage).
+
+### Event Subscriber
+
+**FileSubscriber** (`src/EventSubscriber/FileSubscriber.php`): Extends `AbstractDoctrineListener` from metadata-bundle, registered as a Doctrine listener for `postLoad`, `preFlush`, `onFlush`, and `postFlush` events.
+
+- **postLoad**: Calls `Handler::inject()` on each uploadable field — converts stored relative paths into `Model\File` objects with resolved URI.
+- **preFlush**: Iterates the identity map, calls `Handler::notify()` on each uploadable field — detects file changes and sets the `mappedBy` path field so Doctrine's UnitOfWork sees the change.
+- **onFlush**: Processes scheduled insertions (upload + update path), updates (remove old file + upload new + update path), and deletions (queue file removal/archive). Uses `getScheduledEntityInsertions/Updates/Deletions` filtered by `UploadableConfiguration`. Calls `recomputeSingleEntityChangeSet()` after path updates.
+- **postFlush**: Executes deferred file removals from `$pendingRemove` and archives from `$pendingArchive`. Both arrays store `[entityClass, storageName, paths]` tuples. Removals/archives are deferred to postFlush to ensure database changes succeed before files are affected.
+
+### Handler
+
+**Handler** (`src/Handler/Handler.php`): Registered as `lazy: true` in the service container. Depends on `StorageResolver`, `EventDispatcherInterface`, and `$archivePath`. Resolves the correct storage per entity via `UploadableConfiguration::getStorage()`. Implements six operations:
+- `notify()`: Skips `Model\File` instances (already injected, mappedBy is correct). For other `File` instances, resolves its relative path via `Storage::resolveRelativePath()` and sets it on the `mappedBy` field. Sets `null` if no file.
+- `update()`: After upload, reads the file from the `inversedBy` field and sets the relative path on the `mappedBy` field via `getPathname()`. Handles null/missing files.
+- `upload()`: Derives entity class via `ClassUtils::getClass()`, dispatches `PreUploadEvent` (with `$entityClass`), creates a `NamingStrategyInterface` instance via `NamingStrategyFactory::create()`, delegates to `Storage::upload()`, wraps the result in a `Model\File` with resolved path and URI, then dispatches `PostUploadEvent` (with `$entityClass`).
+- `remove(string $entityClass, string $storageName, ?string $relativePath)`: Resolves path/URI, dispatches `PreRemoveEvent` (with `$entityClass`), calls `Storage::remove()`, dispatches `PostRemoveEvent` (with `$entityClass`).
+- `archive(string $storageName, ?string $relativePath)`: Downloads the file from storage to the archive directory via `Storage::download()`, then removes it from storage. Works with both filesystem and S3 backends.
+- `inject()`: Reads the relative path from `mappedBy`, resolves to absolute path and URI via Storage, creates a `Model\File` instance and sets it on the file property.
+
+### Storage
+
+**StorageInterface** (`src/Storage/StorageInterface.php`): Contract for file operations: `upload`, `remove`, `resolvePath`, `resolveRelativePath`, `resolveUri`, `download`.
+
+**FileSystemStorage** (`src/Storage/FileSystemStorage.php`): `readonly` local filesystem implementation. Constructed with `uploadPath` (absolute directory) and optional `uriPrefix` (web-accessible path prefix or CDN URL). `upload()` creates the target directory if needed, generates a filename via the naming strategy, validates the filename against path traversal (rejects `/`, `\`, `..`), moves the file to `{uploadPath}/{prefix}/`, and returns the relative path. `download()` copies the file from storage to a local target path. `resolvePath()` prepends the upload root. `resolveUri()` prepends the URI prefix (can be a CDN URL for absolute URIs).
+
+**S3Storage** (`src/Storage/S3Storage.php`): `readonly` AWS S3 implementation. Constructed with `S3Client`, `bucket`, and optional `uriPrefix`. `upload()` puts the object to S3 and wraps `S3Exception` in a bundle `RuntimeException`. `remove()` catches `S3Exception` and returns `false` for `NoSuchKey`. `download()` uses `S3Client::getObject()` with `SaveAs` to download files to a local path. `resolveUri()` uses `S3Client::getObjectUrl()` when no URI prefix is configured.
+
+**StorageResolver** (`src/Storage/StorageResolver.php`): Registry of named storages. Each storage defined in the `storages` config is registered by name. The `'default'` alias always points to the configured default storage (explicit via `default_storage` option, or the first enabled storage). Handler uses the resolver to select storage per entity based on `#[Uploadable(storage: '...')]`.
+
+### Naming Strategies
+
+**NamingStrategyInterface** (`src/NamingStrategy/NamingStrategyInterface.php`): Contract with a single `name(File $file): string` method.
+
+**HashingNamingStrategy** (`src/NamingStrategy/HashingNamingStrategy.php`): Default strategy. Generates `md5(originalName + random_bytes) + guessedExtension`.
+
+**OriginNamingStrategy** (`src/NamingStrategy/OriginNamingStrategy.php`): Preserves the original filename (client name for uploads, basename for regular files).
+
+**NamingStrategyFactory** (`src/NamingStrategy/NamingStrategyFactory.php`): Static factory with singleton cache (`$factories` array). Validates that the class exists and implements `NamingStrategyInterface`. Provides `reset()` method for clearing the cache in tests.
+
+### Model
+
+**File** (`src/Model/File.php`): Extends `Symfony\Component\HttpFoundation\File\File` with a `readonly $uri` property. Implements `FileInterface`. Uses `ImageTrait` for image dimension support. Constructed with `(path, uri)` — passes `checkPath: false` to parent since the file may not exist at injection time.
+
+**FileInterface** (`src/Model/FileInterface.php`): Contract requiring `getUri(): string|null`.
+
+### Entity Traits
+
+**FileTrait** (`src/Entity/FileTrait.php`): Provides `$file` (with `#[UploadableProperty(mappedBy: 'filePath')]`) and `$filePath` (`nullable: true` ORM column). Getter returns `File|null`.
+
+**ImageTrait** (`src/Entity/ImageTrait.php`): Same pattern with `$image`/`$imagePath` fields mapped via `#[UploadableProperty(mappedBy: 'imagePath')]`.
+
+**RequiredFileTrait** (`src/Entity/RequiredFileTrait.php`): Like `FileTrait` but `$filePath` column is `nullable: false`, defaults to `''`.
+
+**RequiredImageTrait** (`src/Entity/RequiredImageTrait.php`): Like `ImageTrait` but `$imagePath` column is `nullable: false`, defaults to `''`.
+
+### Events
+
+All events carry `$entityClass` (the fully-qualified entity class name via `ClassUtils::getClass()`), enabling listeners to filter by entity type.
+
+**PreUploadEvent** / **PostUploadEvent** (`src/Events/`): Dispatched before/after file upload. Both carry readonly `$entityClass`, `$entity`, `$file`, and `$fieldName`. `PreUploadEvent` provides the original source file (before storage move). `PostUploadEvent` provides the resolved `Model\File` (after storage). Use `PostUploadEvent` for post-processing such as image resizing, thumbnail generation, or metadata extraction.
+
+**PreRemoveEvent** / **PostRemoveEvent** (`src/Events/`): Dispatched before/after file deletion. Both extend `AbstractEvent` which carries readonly `$entityClass`, `$relativePath`, `$resolvedPath`, and `$resolvedUri`. Subscribe to these to hook into file removal (e.g., clearing CDN cache, removing thumbnails).
+
+### Serializer
+
+**FileNormalizer** (`src/Serializer/Normalizer/FileNormalizer.php`): Symfony Serializer normalizer for `Model\File`. Constructed with `$baseUrl` (wired to `%env(APP_URL)%` by the bundle extension). Normalizes to an absolute URL by prepending `$baseUrl` to relative URIs. Absolute URIs (starting with `http://` or `https://`, e.g. from S3 or CDN storage) are returned as-is. Per-storage CDN is configured by setting the storage's `uri_prefix` to the CDN URL.
+
+### Service Configuration
+
+Services are autowired and autoconfigured via `src/Resources/config/services.php`. The `Handler` is registered as `lazy: true`. Directories excluded from autowiring: `DependencyInjection`, `Resources`, `ExceptionInterface`, `NamingStrategy`, `Model`, `Mapping`, `Entity`, `Events`, `Storage`.
+
+Bundle configuration key is `chamber_orchestra_file`. Storages are defined under `storages` as a named map. Each storage has: `enabled` (default `true`), `driver` (`file_system` or `s3`), `path`, `uri_prefix` (null for private storage, or a CDN URL for absolute URIs), and S3-specific `bucket`, `region`, `endpoint`. S3 storages require `bucket` and `region` (validated at the Configuration tree level). The `default_storage` option selects which storage is the default (if omitted, the first enabled storage is used). The `archive_path` option (default `%kernel.project_dir%/var/archive`) sets the directory for `Behaviour::Archive`. The `FileNormalizer` receives `%env(APP_URL)%` as its base URL for resolving relative URIs. Entities select storage via `#[Uploadable(storage: 'name')]`.
+
+## Code Style
+
+- PHP 8.5+ with strict types (`declare(strict_types=1);`)
+- PSR-4 autoloading: `ChamberOrchestra\FileBundle\` → `src/`
+- `@PER-CS` + `@Symfony` PHP-CS-Fixer rulesets
+- Native function invocations must be backslash-prefixed (e.g., `\array_merge()`, `\sprintf()`, `\count()`)
+- No global namespace imports — never use `use function` or `use const`
+- Nullable types use `?` prefix syntax (e.g., `?string` not `string|null`)
+- Ordered imports (alpha), no unused imports, single quotes, trailing commas in multiline
+- PHPStan level max
+
+## Dependencies
+
+- Requires PHP 8.5, Symfony 8.0 components (`dependency-injection`, `config`, `framework-bundle`, `runtime`, `options-resolver`), and `chamber-orchestra/metadata-bundle` 8.0
+- Dev: PHPUnit 13, `symfony/test-pack`, `symfony/mime`, `symfony/serializer`, `aws/aws-sdk-php`, `friendsofphp/php-cs-fixer`, `phpstan/phpstan`
+- Suggests: `aws/aws-sdk-php` (for S3 storage driver)
+- Main branch is `main`
+
+## Testing Conventions
+
+- Use music thematics for test fixtures and naming (e.g., entity names like `Composition`, `Instrument`, `Rehearsal`, `Score`; file names like `symphony_no_5.pdf`, `violin_concerto.mp3`, `moonlight_sonata.jpg`; prefixes like `scores`, `recordings`)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7e6bee2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,568 @@
+# ChamberOrchestra File Bundle
+
+[](https://packagist.org/packages/chamber-orchestra/file-bundle)
+[](https://packagist.org/packages/chamber-orchestra/file-bundle)
+[](https://packagist.org/packages/chamber-orchestra/file-bundle)
+
+A Symfony bundle for automatic file upload and image upload handling on Doctrine ORM entities. Mark your entity with PHP attributes, and the bundle transparently uploads, injects, and removes files through Doctrine lifecycle events.
+
+Supports local filesystem and Amazon S3 storage backends, multiple named storages, CDN integration, pluggable naming strategies, file archiving, and Doctrine embeddables.
+
+### Features
+
+- **Automatic file uploads** via Doctrine lifecycle events — no manual upload logic
+- **Multiple storage backends** — local filesystem, Amazon S3, MinIO
+- **Per-entity storage** — different entities can use different storages
+- **CDN support** — serve files through CloudFront, Cloudflare, or any CDN
+- **Private/secure storage** — store files outside the web root with controlled access
+- **File archiving** — archive files before deletion instead of permanent removal
+- **Image support** — dimensions, EXIF metadata, orientation detection
+- **Doctrine embeddables** — uploadable fields inside embedded objects
+- **Pluggable naming strategies** — hashing (default), original name, or custom
+- **Symfony Serializer integration** — normalizes files to absolute URLs
+
+## Requirements
+
+- PHP 8.5+
+- Symfony 8.0
+- Doctrine ORM
+
+## Installation
+
+```bash
+composer require chamber-orchestra/file-bundle
+```
+
+For S3 storage support:
+
+```bash
+composer require aws/aws-sdk-php
+```
+
+## Quick Start
+
+### 1. Configure the bundle
+
+```yaml
+# config/packages/chamber_orchestra_file.yaml
+chamber_orchestra_file:
+ storages:
+ default:
+ driver: file_system
+ path: '%kernel.project_dir%/public/uploads'
+ uri_prefix: '/uploads'
+```
+
+### 2. Add attributes to your entity
+
+```php
+use ChamberOrchestra\FileBundle\Mapping\Attribute\Uploadable;
+use ChamberOrchestra\FileBundle\Mapping\Attribute\UploadableProperty;
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\HttpFoundation\File\File;
+
+#[ORM\Entity]
+#[Uploadable]
+class Composition
+{
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column]
+ private ?int $id = null;
+
+ #[ORM\Column(length: 255)]
+ private string $title;
+
+ #[UploadableProperty(mappedBy: 'scorePath')]
+ private ?File $score = null;
+
+ #[ORM\Column(length: 255, nullable: true)]
+ private ?string $scorePath = null;
+
+ // getters and setters...
+
+ public function getScore(): ?File
+ {
+ return $this->score;
+ }
+
+ public function setScore(?File $score): void
+ {
+ $this->score = $score;
+ }
+}
+```
+
+### 3. Upload a file
+
+```php
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+
+$composition = new Composition();
+$composition->setTitle('Symphony No. 5');
+$composition->setScore($uploadedFile);
+
+$entityManager->persist($composition);
+$entityManager->flush();
+```
+
+That's it. The bundle handles the rest:
+- Moves the file to the configured storage path
+- Persists the relative path in `scorePath`
+- On subsequent loads, injects a `Model\File` object with the resolved path and URI
+
+### 4. Access the file
+
+After loading the entity from the database, the `score` property holds a `ChamberOrchestra\FileBundle\Model\File` instance:
+
+```php
+$composition = $entityManager->find(Composition::class, 1);
+
+$file = $composition->getScore();
+$file->getUri(); // "/uploads/symphony_no_5_a1b2c3.pdf"
+$file->getPathname(); // "/var/www/public/uploads/symphony_no_5_a1b2c3.pdf"
+```
+
+## Configuration
+
+The bundle supports multiple named storages. Each storage is defined under the `storages` key and can use different drivers and settings.
+
+### Local Filesystem
+
+```yaml
+chamber_orchestra_file:
+ storages:
+ default:
+ driver: file_system
+ path: '%kernel.project_dir%/public/uploads'
+ uri_prefix: '/uploads'
+```
+
+### Amazon S3
+
+```yaml
+chamber_orchestra_file:
+ storages:
+ default:
+ driver: s3
+ bucket: my-recordings-bucket
+ region: us-east-1
+ uri_prefix: '/uploads' # optional, uses S3 URLs if omitted
+ endpoint: 'http://localhost:9000' # optional, for MinIO/localstack
+```
+
+### Multiple Storages
+
+Define as many storages as you need. Each entity can select which storage to use via the `#[Uploadable]` attribute:
+
+```yaml
+chamber_orchestra_file:
+ default_storage: public # optional, first enabled storage is used if omitted
+ storages:
+ public:
+ driver: file_system
+ path: '%kernel.project_dir%/public/uploads'
+ uri_prefix: '/uploads'
+ secure:
+ driver: file_system
+ path: '%kernel.project_dir%/var/share'
+ archive:
+ driver: s3
+ bucket: orchestra-archive
+ region: eu-west-1
+```
+
+When only one storage is defined, it becomes the default automatically.
+
+### Secure Storage (private files)
+
+For files that should not be publicly accessible (contracts, invoices, internal documents), define a storage without a `uri_prefix`:
+
+```yaml
+chamber_orchestra_file:
+ storages:
+ default:
+ driver: file_system
+ path: '%kernel.project_dir%/public/uploads'
+ uri_prefix: '/uploads'
+ secure:
+ driver: file_system
+ path: '%kernel.project_dir%/var/share'
+```
+
+Files stored via a storage with no URI prefix have `getUri()` returning `null`. To serve them, use a controller that reads the file and streams the response with appropriate access control:
+
+```php
+#[Uploadable(storage: 'secure', prefix: 'contracts')]
+class Contract
+{
+ #[UploadableProperty(mappedBy: 'documentPath')]
+ private ?File $document = null;
+
+ #[ORM\Column(nullable: true)]
+ private ?string $documentPath = null;
+}
+```
+
+### Disabling a Storage
+
+A storage can be temporarily disabled without removing its configuration:
+
+```yaml
+chamber_orchestra_file:
+ storages:
+ default:
+ driver: file_system
+ path: '%kernel.project_dir%/public/uploads'
+ uri_prefix: '/uploads'
+ staging:
+ enabled: false
+ driver: s3
+ bucket: staging-uploads
+ region: us-east-1
+```
+
+### Configuration Reference
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `default_storage` | `string\|null` | `null` | Name of the default storage. If null, the first enabled storage is used |
+| `storages` | `map` | required | Named storage definitions (at least one required) |
+| `storages.*.enabled` | `bool` | `true` | Whether this storage is active |
+| `storages.*.driver` | `string` | `file_system` | Storage driver: `file_system` or `s3` |
+| `storages.*.path` | `string` | `%kernel.project_dir%/public/uploads` | Filesystem path (file_system driver) |
+| `storages.*.uri_prefix` | `string\|null` | `null` | Public URI prefix. Null means files are not web-accessible |
+| `storages.*.bucket` | `string\|null` | `null` | S3 bucket name (required for s3 driver) |
+| `storages.*.region` | `string\|null` | `null` | AWS region (required for s3 driver) |
+| `storages.*.endpoint` | `string\|null` | `null` | Custom S3 endpoint for MinIO/localstack |
+| `archive_path` | `string` | `%kernel.project_dir%/var/archive` | Local directory for archived files (`Behaviour::Archive`) |
+
+## Entity Attributes
+
+### `#[Uploadable]`
+
+Applied to the entity class. Options:
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `prefix` | `string` | `''` | Subdirectory within the storage path |
+| `namingStrategy` | `string` | `HashingNamingStrategy::class` | Class implementing `NamingStrategyInterface` |
+| `behaviour` | `Behaviour` | `Behaviour::Remove` | What happens to files on entity update/delete |
+| `storage` | `string` | `'default'` | Named storage backend to use |
+
+```php
+use ChamberOrchestra\FileBundle\Mapping\Attribute\Uploadable;
+use ChamberOrchestra\FileBundle\Mapping\Helper\Behaviour;
+use ChamberOrchestra\FileBundle\NamingStrategy\OriginNamingStrategy;
+
+#[Uploadable(
+ prefix: 'scores',
+ namingStrategy: OriginNamingStrategy::class,
+ behaviour: Behaviour::Keep,
+ storage: 'archive',
+)]
+class Score
+{
+ // ...
+}
+```
+
+### `#[UploadableProperty]`
+
+Applied to file properties. Options:
+
+| Option | Type | Description |
+|---|---|---|
+| `mappedBy` | `string` | Name of the string property that stores the relative file path |
+
+The `mappedBy` property must exist on the same class and be a Doctrine-mapped column.
+
+## Behaviour
+
+The `Behaviour` enum controls what happens to files when an entity is updated or deleted:
+
+- `Behaviour::Remove` (default) — old files are deleted from storage after a successful flush
+- `Behaviour::Keep` — old files remain in storage (useful for audit trails or versioning)
+- `Behaviour::Archive` — old files are moved to a local archive directory before being removed from storage
+
+### Archiving
+
+When using `Behaviour::Archive`, files are downloaded from their storage backend (including S3) and saved to a local archive directory before deletion. Configure the archive path:
+
+```yaml
+chamber_orchestra_file:
+ archive_path: '%kernel.project_dir%/var/archive' # default
+ storages:
+ default:
+ driver: file_system
+ path: '%kernel.project_dir%/public/uploads'
+ uri_prefix: '/uploads'
+```
+
+```php
+#[Uploadable(behaviour: Behaviour::Archive, prefix: 'contracts')]
+class Contract
+{
+ // Files are archived to var/archive/contracts/ before removal
+}
+```
+
+## Naming Strategies
+
+### `HashingNamingStrategy` (default)
+
+Generates a unique filename using MD5 hash with random bytes, preserving the guessed file extension:
+
+```
+a1b2c3d4e5f67890abcdef1234567890.pdf
+```
+
+### `OriginNamingStrategy`
+
+Preserves the original filename as uploaded by the client. Automatically appends a version suffix (`_1`, `_2`, etc.) when a file with the same name already exists in the target directory:
+
+```
+moonlight_sonata.pdf
+moonlight_sonata_1.pdf # if moonlight_sonata.pdf already exists
+moonlight_sonata_2.pdf # if _1 also exists
+```
+
+### Custom Naming Strategy
+
+Implement `NamingStrategyInterface`:
+
+```php
+use ChamberOrchestra\FileBundle\NamingStrategy\NamingStrategyInterface;
+use Symfony\Component\HttpFoundation\File\File;
+
+class TimestampNamingStrategy implements NamingStrategyInterface
+{
+ public function name(File $file, string $targetDir = ''): string
+ {
+ return \time() . '_' . \bin2hex(\random_bytes(4)) . '.' . $file->guessExtension();
+ }
+}
+```
+
+Then reference it in the attribute:
+
+```php
+#[Uploadable(namingStrategy: TimestampNamingStrategy::class)]
+```
+
+## Entity Traits
+
+The bundle provides convenience traits for common file/image patterns:
+
+```php
+use ChamberOrchestra\FileBundle\Entity\FileTrait;
+use ChamberOrchestra\FileBundle\Mapping\Attribute\Uploadable;
+
+#[ORM\Entity]
+#[Uploadable(prefix: 'recordings')]
+class Recording
+{
+ use FileTrait; // adds $file + $filePath + getFile()
+
+ #[ORM\Id, ORM\GeneratedValue, ORM\Column]
+ private ?int $id = null;
+}
+```
+
+Available traits:
+
+| Trait | Properties | Nullable |
+|---|---|---|
+| `FileTrait` | `$file` / `$filePath` | Yes |
+| `RequiredFileTrait` | `$file` / `$filePath` | No |
+| `ImageTrait` | `$image` / `$imagePath` | Yes |
+| `RequiredImageTrait` | `$image` / `$imagePath` | No |
+
+## Multiple File Fields
+
+An entity can have multiple uploadable properties:
+
+```php
+#[ORM\Entity]
+#[Uploadable(prefix: 'compositions')]
+class Composition
+{
+ #[UploadableProperty(mappedBy: 'scorePath')]
+ private ?File $score = null;
+
+ #[ORM\Column(nullable: true)]
+ private ?string $scorePath = null;
+
+ #[UploadableProperty(mappedBy: 'recordingPath')]
+ private ?File $recording = null;
+
+ #[ORM\Column(nullable: true)]
+ private ?string $recordingPath = null;
+}
+```
+
+## Doctrine Embeddables
+
+The bundle supports uploadable fields inside Doctrine embeddables:
+
+```php
+#[ORM\Embeddable]
+#[Uploadable(prefix: 'media')]
+class MediaEmbed
+{
+ #[UploadableProperty(mappedBy: 'coverPath')]
+ private ?File $cover = null;
+
+ #[ORM\Column(nullable: true)]
+ private ?string $coverPath = null;
+}
+
+#[ORM\Entity]
+#[Uploadable]
+class Album
+{
+ #[ORM\Embedded(class: MediaEmbed::class)]
+ private MediaEmbed $media;
+}
+```
+
+## Image Support
+
+`Model\File` includes `ImageTrait` with image-specific helpers:
+
+```php
+$image = $album->getCover();
+
+if ($image->isImage()) {
+ $image->getWidth(); // 1920
+ $image->getHeight(); // 1080
+ $image->getRatio(); // 1.78
+ $image->getOrientation(); // EXIF orientation value
+ $image->getOrientationAngle(); // 90, 180, -90, or 0
+ $image->getMetadata(); // Full EXIF data array
+}
+```
+
+## Events
+
+The bundle dispatches events around file upload and removal, allowing you to hook in for image processing, cache clearing, thumbnail cleanup, CDN invalidation, etc.
+
+All events carry `$entityClass` — the FQCN of the entity that triggered the event. This allows listeners to target specific entity types.
+
+| Event | Dispatched |
+|---|---|
+| `PreUploadEvent` | Before a file is uploaded to storage |
+| `PostUploadEvent` | After a file is uploaded to storage |
+| `PreRemoveEvent` | Before a file is deleted from storage |
+| `PostRemoveEvent` | After a file is deleted from storage |
+
+### Upload Events
+
+Upload events carry `$entityClass`, `$entity`, `$file`, and `$fieldName`. Use `PostUploadEvent` for post-processing like image resizing:
+
+```php
+use ChamberOrchestra\FileBundle\Events\PostUploadEvent;
+use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
+
+#[AsEventListener]
+class ImageResizeListener
+{
+ public function __invoke(PostUploadEvent $event): void
+ {
+ // Only process images for specific entity types
+ if (Album::class !== $event->entityClass) {
+ return;
+ }
+
+ if ($event->file->isImage()) {
+ $this->resizer->resize($event->file->getPathname(), 1920, 1080);
+ }
+ }
+}
+```
+
+### Removal Events
+
+Removal events carry `$entityClass`, `$relativePath`, `$resolvedPath`, and `$resolvedUri`:
+
+```php
+use ChamberOrchestra\FileBundle\Events\PreRemoveEvent;
+use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
+
+#[AsEventListener]
+class ThumbnailCleanupListener
+{
+ public function __invoke(PreRemoveEvent $event): void
+ {
+ // Clear thumbnails for the file being removed
+ $this->thumbnailService->purge($event->relativePath);
+ }
+}
+```
+
+## Serializer Integration
+
+The bundle includes a Symfony Serializer normalizer for `Model\File` that outputs an absolute URL.
+
+The normalizer uses the `APP_URL` environment variable as the base URL:
+
+```php
+$serializer->normalize($composition);
+// "score" => "https://example.com/uploads/scores/a1b2c3.pdf"
+```
+
+When a file's URI is already an absolute URL (common with S3 or CDN storages), the normalizer returns it as-is without prepending the base:
+
+```php
+// S3 storage with no uri_prefix — URI is already absolute
+$serializer->normalize($recording);
+// "score" => "https://my-bucket.s3.amazonaws.com/scores/a1b2c3.pdf"
+
+// Storage with CDN uri_prefix — URI is already absolute
+// uri_prefix: 'https://cdn.example.com/uploads'
+$serializer->normalize($recording);
+// "score" => "https://cdn.example.com/uploads/scores/a1b2c3.pdf"
+```
+
+### CDN Support
+
+To serve files through a CDN, set the storage's `uri_prefix` to the CDN base URL:
+
+```yaml
+chamber_orchestra_file:
+ storages:
+ default:
+ driver: file_system
+ path: '%kernel.project_dir%/public/uploads'
+ uri_prefix: 'https://cdn.example.com/uploads'
+```
+
+Files will be injected with the CDN URL as their URI, and the serializer will pass it through unchanged.
+
+## Security
+
+The default storage path (`%kernel.project_dir%/public/uploads`) is inside the web root. If your web server is configured to execute scripts (PHP, Python, etc.) from that directory, an uploaded file could be executed via HTTP.
+
+**Disable script execution** in your upload directories:
+
+Nginx:
+
+```nginx
+location /uploads {
+ location ~ \.(php|phtml|php[0-9])$ {
+ deny all;
+ }
+}
+```
+
+Apache (`.htaccess` in the uploads directory):
+
+```apache
+
+ Require all denied
+
+```
+
+Alternatively, store files **outside the web root** by using a storage path like `%kernel.project_dir%/var/files` with no `uri_prefix`, and serve them through a controller with access control.
+
+## License
+
+MIT License. See [LICENSE](LICENSE) for details.
diff --git a/composer.json b/composer.json
index 67a6e85..3ffcab9 100644
--- a/composer.json
+++ b/composer.json
@@ -1,18 +1,84 @@
{
- "name": "dev/form-bundle",
- "type": "library",
- "description": "",
- "keywords": [],
- "homepage": "",
+ "name": "chamber-orchestra/file-bundle",
+ "type": "symfony-bundle",
+ "description": "Symfony bundle for automatic file upload handling on Doctrine ORM entities with S3, CDN, and multiple storage support",
+ "keywords": [
+ "symfony",
+ "symfony-bundle",
+ "file",
+ "file-upload",
+ "upload",
+ "doctrine",
+ "orm",
+ "storage",
+ "s3",
+ "aws",
+ "cdn",
+ "image",
+ "image-upload",
+ "file-manager",
+ "file-storage",
+ "minio"
+ ],
+ "homepage": "https://github.com/chamber-orchestra/file-bundle",
"license": "MIT",
- "authors": [],
+ "authors": [
+ {
+ "name": "Andrew Lukin",
+ "email": "lukin.andrej@gmail.com",
+ "homepage": "https://github.com/wtorsi",
+ "role": "Developer"
+ }
+ ],
"require": {
- "php": "^8.3"
+ "php": "^8.5",
+ "symfony/dependency-injection": "8.0.*",
+ "symfony/config": "8.0.*",
+ "symfony/framework-bundle": "8.0.*",
+ "symfony/runtime": "8.0.*",
+ "symfony/options-resolver": "8.0.*",
+ "chamber-orchestra/metadata-bundle": "8.0.*"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0",
+ "symfony/test-pack": "^1.2",
+ "symfony/mime": "^8.0",
+ "symfony/serializer": "^8.0",
+ "aws/aws-sdk-php": "^3.0",
+ "friendsofphp/php-cs-fixer": "^3.94",
+ "phpstan/phpstan": "^2.1"
+ },
+ "suggest": {
+ "aws/aws-sdk-php": "Required for S3 storage driver (^3.0)"
},
"autoload": {
"psr-4": {
- "Dev\\FormBundle\\": ""
+ "ChamberOrchestra\\FileBundle\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tests\\": "tests/"
+ }
+ },
+ "minimum-stability": "stable",
+ "conflict": {
+ "symfony/symfony": "*"
+ },
+ "extra": {
+ "symfony": {
+ "allow-contrib": false,
+ "require": "8.0.*"
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "symfony/runtime": true
}
},
- "minimum-stability": "dev"
+ "scripts": {
+ "test": "vendor/bin/phpunit",
+ "analyse": "vendor/bin/phpstan analyse",
+ "cs-check": "vendor/bin/php-cs-fixer fix --dry-run --diff"
+ }
}
diff --git a/src/ChamberOrchestraFileBundle.php b/src/ChamberOrchestraFileBundle.php
index e8a7379..dd6d986 100644
--- a/src/ChamberOrchestraFileBundle.php
+++ b/src/ChamberOrchestraFileBundle.php
@@ -2,10 +2,17 @@
declare(strict_types=1);
-namespace Dev\FileBundle;
+/*
+ * This file is part of the ChamberOrchestra package.
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace ChamberOrchestra\FileBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
-class DevFileBundle extends Bundle
+final class ChamberOrchestraFileBundle extends Bundle
{
}
diff --git a/src/DependencyInjection/ChamberOrchestraFileExtension.php b/src/DependencyInjection/ChamberOrchestraFileExtension.php
index 0f2dd7a..cb86595 100644
--- a/src/DependencyInjection/ChamberOrchestraFileExtension.php
+++ b/src/DependencyInjection/ChamberOrchestraFileExtension.php
@@ -2,30 +2,127 @@
declare(strict_types=1);
-namespace Dev\FileBundle\DependencyInjection;
+/*
+ * This file is part of the ChamberOrchestra package.
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
-use Dev\FileBundle\Handler\Handler;
+namespace ChamberOrchestra\FileBundle\DependencyInjection;
+
+use Aws\S3\S3Client;
+use ChamberOrchestra\FileBundle\Handler\Handler;
+use ChamberOrchestra\FileBundle\Serializer\Normalizer\FileNormalizer;
+use ChamberOrchestra\FileBundle\Storage\FileSystemStorage;
+use ChamberOrchestra\FileBundle\Storage\S3Storage;
+use ChamberOrchestra\FileBundle\Storage\StorageResolver;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
-use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
-use Symfony\Component\Filesystem\Filesystem;
-use Symfony\Component\HttpFoundation\File\File;
+use Symfony\Component\DependencyInjection\Definition;
+use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
+use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\DependencyInjection\ConfigurableExtension;
-class DevFileExtension extends ConfigurableExtension
+final class ChamberOrchestraFileExtension extends ConfigurableExtension
{
+ /**
+ * @param array $configs
+ */
public function loadInternal(array $configs, ContainerBuilder $container): void
{
- $container->setAlias('dev_file.storage', 'dev_file.storage.'.$configs['storage']['driver']);
- $container->setParameter('dev_file.storage.uri_prefix', $configs['storage']['uri_prefix']);
- $container->setParameter('dev_file.storage.path', $configs['storage']['path']);
+ $loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
+ $loader->load('services.php');
+
+ /** @var array $storages */
+ $storages = $configs['storages'] ?? [];
+ $enabledStorages = \array_filter($storages, static fn (array $s): bool => $s['enabled'] ?? true);
+
+ if ([] === $enabledStorages) {
+ throw new \LogicException('At least one storage must be defined under "chamber_orchestra_file.storages".');
+ }
+
+ /** @var string $defaultName */
+ $defaultName = $configs['default_storage'] ?? \array_key_first($enabledStorages);
+
+ if (!isset($enabledStorages[$defaultName])) {
+ throw new \LogicException(\sprintf('Default storage "%s" is not defined or not enabled. Available storages: %s.', $defaultName, \implode(', ', \array_keys($enabledStorages))));
+ }
+
+ $resolver = $container->getDefinition(StorageResolver::class);
+
+ foreach ($enabledStorages as $name => $storage) {
+ $serviceId = 'chamber_orchestra_file.storage.'.$name;
+
+ match ($storage['driver']) {
+ 'file_system' => $this->registerFileSystemStorage($container, $serviceId, $storage),
+ 's3' => $this->registerS3Storage($container, $serviceId, $name, $storage),
+ default => throw new \LogicException(\sprintf('Unsupported storage driver "%s".', $storage['driver'])),
+ };
+
+ $resolver->addMethodCall('add', [$name, new Reference($serviceId)]);
+ }
+
+ // Register the default storage as 'default' if it has a different name
+ if ('default' !== $defaultName) {
+ $resolver->addMethodCall('add', ['default', new Reference('chamber_orchestra_file.storage.'.$defaultName)]);
+ }
- $this->loadServicesFiles($container, $configs);
+ /** @var string $archivePath */
+ $archivePath = $configs['archive_path'] ?? '%kernel.project_dir%/var/archive';
+
+ $container->getDefinition(Handler::class)
+ ->setArgument('$archivePath', $archivePath);
+
+ if ($container->hasDefinition(FileNormalizer::class)) {
+ $container->getDefinition(FileNormalizer::class)
+ ->setArgument('$baseUrl', '%env(APP_URL)%');
+ }
+ }
+
+ /**
+ * @param array{path: string, uri_prefix: string|null} $storage
+ */
+ private function registerFileSystemStorage(ContainerBuilder $container, string $serviceId, array $storage): void
+ {
+ $definition = new Definition(FileSystemStorage::class);
+ $definition->setArguments([
+ $storage['path'],
+ $storage['uri_prefix'],
+ ]);
+ $container->setDefinition($serviceId, $definition);
}
- private function loadServicesFiles(ContainerBuilder $container, array $configs): void
+ /**
+ * @param array{driver: string, path: string, uri_prefix: string|null, enabled?: bool, bucket?: string|null, region?: string|null, endpoint?: string|null} $storage
+ */
+ private function registerS3Storage(ContainerBuilder $container, string $serviceId, string $name, array $storage): void
{
- $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
- $loader->load('services.yaml');
+ if (!\class_exists(S3Client::class)) {
+ throw new \LogicException('The "aws/aws-sdk-php" package is required for S3 storage. Install it with "composer require aws/aws-sdk-php".');
+ }
+
+ /** @var string $region */
+ $region = $storage['region'] ?? 'us-east-1';
+
+ $clientArgs = [
+ 'region' => $region,
+ 'version' => 'latest',
+ ];
+
+ if (null !== ($storage['endpoint'] ?? null)) {
+ $clientArgs['endpoint'] = $storage['endpoint'];
+ }
+
+ $clientDefinition = new Definition(S3Client::class, [$clientArgs]);
+ $container->setDefinition($serviceId.'.client', $clientDefinition);
+
+ $s3Definition = new Definition(S3Storage::class);
+ $s3Definition->setArguments([
+ $clientDefinition,
+ $storage['bucket'] ?? '',
+ $storage['uri_prefix'],
+ ]);
+ $container->setDefinition($serviceId, $s3Definition);
}
}
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index 7a4facb..824f4e4 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -2,66 +2,79 @@
declare(strict_types=1);
-namespace Dev\FileBundle\DependencyInjection;
+/*
+ * This file is part of the ChamberOrchestra package.
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace ChamberOrchestra\FileBundle\DependencyInjection;
-use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
-class Configuration implements ConfigurationInterface
+final class Configuration implements ConfigurationInterface
{
- protected $supportedDbDrivers = ['orm'];
- protected $supportedStorage = ['file_system'];
+ private const array SUPPORTED_STORAGE_DRIVERS = ['file_system', 's3'];
- public function getConfigTreeBuilder() : TreeBuilder
+ public function getConfigTreeBuilder(): TreeBuilder
{
- $tb = new TreeBuilder('dev_file');
- $root = $tb->getRootNode();
- $this->addGeneralSection($root);
-
- return $tb;
- }
+ $tb = new TreeBuilder('chamber_orchestra_file');
- protected function addGeneralSection(ArrayNodeDefinition $node) : void
- {
- $node
+ $tb->getRootNode()
->children()
- ->scalarNode('db_driver')
- ->defaultValue('%dev_file.db_driver%')
- ->beforeNormalization()
- ->ifString()
- ->then(function ($v) {
- return strtolower($v);
- })
+ ->scalarNode('default_storage')
+ ->defaultNull()
+ ->info('Name of the default storage. If null, the first defined storage is used.')
->end()
- ->validate()
- ->ifNotInArray($this->supportedDbDrivers)
- ->thenInvalid('The db driver %s is not supported. Please choose one of '.implode(', ', $this->supportedDbDrivers))
+ ->scalarNode('archive_path')
+ ->defaultValue('%kernel.project_dir%/var/archive')
+ ->info('Directory where files are moved when using Behaviour::Archive.')
->end()
- ->end()
- ->arrayNode('storage')
- ->children()
- ->scalarNode('driver')->defaultValue('file_system')->isRequired()
- ->beforeNormalization()
- ->ifString()
- ->then(function ($v) {
- return strtolower($v);
- })
- ->end()
- ->validate()
- ->ifNotInArray($this->supportedStorage)
- ->thenInvalid('The storage %s is not supported. Please choose one of '.implode(', ', $this->supportedStorage))
+ ->arrayNode('storages')
+ ->useAttributeAsKey('name')
+ ->requiresAtLeastOneElement()
+ ->arrayPrototype()
+ ->canBeEnabled()
+ ->children()
+ ->enumNode('driver')
+ ->values(self::SUPPORTED_STORAGE_DRIVERS)
+ ->defaultValue('file_system')
+ ->beforeNormalization()
+ ->ifString()
+ ->then(static fn (string $v): string => \strtolower($v))
+ ->end()
+ ->end()
+ ->scalarNode('path')
+ ->defaultValue('%kernel.project_dir%/public/uploads')
+ ->end()
+ ->scalarNode('uri_prefix')
+ ->defaultNull()
+ ->end()
+ ->scalarNode('bucket')
+ ->defaultNull()
+ ->end()
+ ->scalarNode('region')
+ ->defaultNull()
+ ->end()
+ ->scalarNode('endpoint')
+ ->defaultNull()
+ ->end()
+ ->end()
+ ->validate()
+ ->ifTrue(static fn (array $v): bool => 's3' === ($v['driver'] ?? 'file_system') && (null === $v['bucket'] || '' === $v['bucket']))
+ ->thenInvalid('The "bucket" option is required when driver is "s3".')
+ ->end()
+ ->validate()
+ ->ifTrue(static fn (array $v): bool => 's3' === ($v['driver'] ?? 'file_system') && (null === $v['region'] || '' === $v['region']))
+ ->thenInvalid('The "region" option is required when driver is "s3".')
->end()
- ->end()
- ->scalarNode('path')
- ->isRequired()
- ->defaultValue('%kernel.project_dir%/public/uploads')
- ->end()
- ->scalarNode('uri_prefix')
- ->defaultValue('/uploads')
->end()
->end()
->end()
- ->end();
+ ;
+
+ return $tb;
}
}
diff --git a/src/Entity/FileTrait.php b/src/Entity/FileTrait.php
index f7066fc..42fb0da 100644
--- a/src/Entity/FileTrait.php
+++ b/src/Entity/FileTrait.php
@@ -2,24 +2,31 @@
declare(strict_types=1);
-namespace Dev\FileBundle\Entity;
+/*
+ * This file is part of the ChamberOrchestra package.
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
-use Dev\FileBundle\Mapping\Annotation as Dev;
+namespace ChamberOrchestra\FileBundle\Entity;
+
+use ChamberOrchestra\FileBundle\Mapping\Attribute as Dev;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
trait FileTrait
{
#[Dev\UploadableProperty(mappedBy: 'filePath')]
- protected File|null $file = null;
+ protected ?File $file = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
- protected string|null $filePath = null;
+ protected ?string $filePath = null;
/**
- * @return File|\Dev\FileBundle\Model\File
+ * @return File|\ChamberOrchestra\FileBundle\Model\File
*/
- public function getFile(): File|null
+ public function getFile(): ?File
{
return $this->file;
}
-}
\ No newline at end of file
+}
diff --git a/src/Entity/ImageTrait.php b/src/Entity/ImageTrait.php
new file mode 100644
index 0000000..759dc45
--- /dev/null
+++ b/src/Entity/ImageTrait.php
@@ -0,0 +1,32 @@
+image;
+ }
+}
diff --git a/src/Entity/RequiredFileTrait.php b/src/Entity/RequiredFileTrait.php
index acdb14d..7e39226 100644
--- a/src/Entity/RequiredFileTrait.php
+++ b/src/Entity/RequiredFileTrait.php
@@ -2,25 +2,31 @@
declare(strict_types=1);
-namespace Dev\FileBundle\Entity;
+/*
+ * This file is part of the ChamberOrchestra package.
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
-use Dev\FileBundle\Mapping\Annotation as Dev;
+namespace ChamberOrchestra\FileBundle\Entity;
+
+use ChamberOrchestra\FileBundle\Mapping\Attribute as Dev;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
-use Symfony\Component\Serializer\Annotation\Groups;
trait RequiredFileTrait
{
#[Dev\UploadableProperty(mappedBy: 'filePath')]
- protected File|null $file = null;
+ protected ?File $file = null;
#[ORM\Column(type: 'string', length: 255, nullable: false)]
- protected string $filePath;
+ protected string $filePath = '';
/**
- * @return File|\Dev\FileBundle\Model\File
+ * @return File|\ChamberOrchestra\FileBundle\Model\File
*/
- public function getFile(): File|null
+ public function getFile(): ?File
{
return $this->file;
}
-}
\ No newline at end of file
+}
diff --git a/src/Entity/RequiredImageTrait.php b/src/Entity/RequiredImageTrait.php
new file mode 100644
index 0000000..0cf5ae5
--- /dev/null
+++ b/src/Entity/RequiredImageTrait.php
@@ -0,0 +1,32 @@
+image;
+ }
+}
diff --git a/src/EventSubscriber/FileSubscriber.php b/src/EventSubscriber/FileSubscriber.php
index 38f18f2..c17618a 100644
--- a/src/EventSubscriber/FileSubscriber.php
+++ b/src/EventSubscriber/FileSubscriber.php
@@ -2,16 +2,24 @@
declare(strict_types=1);
-namespace Dev\FileBundle\EventSubscriber;
-
-use Dev\DoctrineExtensionsBundle\Util\ClassUtils;
-use Dev\FileBundle\Handler\HandlerInterface;
-use Dev\FileBundle\Mapping\Configuration\UploadableConfiguration;
-use Dev\FileBundle\Mapping\Helper\Behaviour;
-use Dev\MetadataBundle\EventSubscriber\AbstractDoctrineListener;
-use Dev\MetadataBundle\Helper\MetadataArgs;
+/*
+ * This file is part of the ChamberOrchestra package.
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace ChamberOrchestra\FileBundle\EventSubscriber;
+
+use ChamberOrchestra\FileBundle\Handler\Handler;
+use ChamberOrchestra\FileBundle\Mapping\Configuration\UploadableConfiguration;
+use ChamberOrchestra\FileBundle\Mapping\Helper\Behaviour;
+use ChamberOrchestra\MetadataBundle\EventSubscriber\AbstractDoctrineListener;
+use ChamberOrchestra\MetadataBundle\Helper\MetadataArgs;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
+use Doctrine\Common\Util\ClassUtils;
use Doctrine\ORM\Event\OnFlushEventArgs;
+use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Event\PostLoadEventArgs;
use Doctrine\ORM\Event\PreFlushEventArgs;
use Doctrine\ORM\Events;
@@ -23,9 +31,21 @@
#[AsDoctrineListener(Events::postFlush)]
class FileSubscriber extends AbstractDoctrineListener
{
+ /** @var array}> */
private array $pendingRemove = [];
+ /** @var array}> */
+ private array $pendingArchive = [];
- public function __construct(private readonly HandlerInterface $handler)
+ /**
+ * Caches which entity classes are uploadable to avoid repeated metadata lookups.
+ * This is the main performance optimization: postLoad and preFlush run for every
+ * entity, but typically only a few classes are uploadable.
+ *
+ * @var array
+ */
+ private array $uploadableClassCache = [];
+
+ public function __construct(private readonly Handler $handler)
{
}
@@ -33,13 +53,22 @@ public function postLoad(PostLoadEventArgs $event): void
{
$entity = $event->getObject();
$em = $event->getObjectManager();
+ $className = ClassUtils::getClass($entity);
+
+ if (false === ($this->uploadableClassCache[$className] ?? null)) {
+ return;
+ }
+
+ $metadata = $this->requireReader()->getExtensionMetadata($em, $className);
+ $config = $metadata->getConfiguration(UploadableConfiguration::class);
+ if (!$config instanceof UploadableConfiguration) {
+ $this->uploadableClassCache[$className] = false;
- $metadata = $this->reader->getExtensionMetadata($em, ClassUtils::getClass($entity));
- /** @var UploadableConfiguration $config */
- if (null === $config = $metadata->getConfiguration(UploadableConfiguration::class)) {
return;
}
+ $this->uploadableClassCache[$className] = true;
+
foreach ($config->getUploadableFieldNames() as $fieldName) {
$this->handler->inject($metadata, $entity, $fieldName);
}
@@ -50,23 +79,53 @@ public function preFlush(PreFlushEventArgs $args): void
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
+
+ foreach ($this->iterateEntities($uow) as $entity) {
+ $className = ClassUtils::getClass($entity);
+
+ if (false === ($this->uploadableClassCache[$className] ?? null)) {
+ continue;
+ }
+
+ $metadata = $this->requireReader()->getExtensionMetadata($em, $className);
+ $config = $metadata->getConfiguration(UploadableConfiguration::class);
+ if (!$config instanceof UploadableConfiguration) {
+ $this->uploadableClassCache[$className] = false;
+
+ continue;
+ }
+
+ $this->uploadableClassCache[$className] = true;
+
+ foreach ($config->getUploadableFieldNames() as $fieldName) {
+ $this->handler->notify($metadata, $entity, $fieldName);
+ }
+ }
+ }
+
+ /**
+ * @return iterable