Symfony bundle for automatic file upload handling on Doctrine ORM entities with S3, CDN, and multiple storage support
Developer toolsPHPGitHub @chamber-orchestraOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
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.Read the full README ↗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.
composer require chamber-orchestra/file-bundle
For S3 storage support:
composer require aws/aws-sdk-php
# config/packages/chamber_orchestra_file.yaml
chamber_orchestra_file:
storages:
default:
driver: file_system
path: '%kernel.project_dir%/public/uploads'
uri_prefix: '/uploads'
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;
}
}
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:
scorePathModel\File object with the resolved path and URIAfter loading the entity from the database, the score property holds a ChamberOrchestra\FileBundle\Model\File instance:
$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"
The bundle supports multiple named storages. Each storage is defined under the storages key and can use different drivers and settings.
chamber_orchestra_file:
storages:
default:
driver: file_system
path: '%kernel.project_dir%/public/uploads'
uri_prefix: '/uploads'
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
download() writes the object body straight to the target path. If it fails (a timeout or a dropped connection
mid-body, for example), the target file may hold a partial body, or the body of the S3 error answer (XML) that the
SDK wrote there. The bundle does not delete it, also not in the
archive path (Behaviour::Archive): the caller deletes it.
Two optional blocks set the HTTP timeouts and the retry policy of the storage's S3Client:
chamber_orchestra_file:
storages:
default:
driver: s3
bucket: my-recordings-bucket
region: eu-west-2
credentials:
key: '%env(S3_KEY)%'
secret: '%env(S3_SECRET)%'
http:
connect_timeout: 5 # seconds, finite, >= 0.001: the connection phase only
timeout: 10 # seconds, finite, >= 0.001: the whole transfer, connection included
retries:
mode: standard # legacy, standard or adaptive
max_attempts: 2 # >= 1, the first attempt included
If a block is set, every key of that block is required. A missing, unknown or invalid key (a timeout that is
infinite (.inf) or below 0.001, a non-number, a mode outside the list, a non-integer or < 1 max_attempts)
fails configuration processing with an InvalidConfigurationException that names the key path, e.g.
chamber_orchestra_file.storages.default.http.
The timeouts must be finite and at least 0.001 because Guzzle cannot use other values: Guzzle 7 silently disables
such a timeout, and Guzzle 8 rejects every request. To have no timeout, leave http unset.
Write the values as literals. An environment placeholder (%env(float:…)%, %env(int:…)%) fails this validation:
Symfony validates a placeholder with a dummy value (0 for float and int), which is below 0.001 (or below 1
for max_attempts).
A block that is not set is not passed to the client: without both blocks the S3Client arguments are exactly those
of 8.1.4, and the SDK defaults apply.
The file_system driver accepts and ignores both blocks, so an environment can override an s3 storage with
driver: file_system without removing them.
The timeouts are enforced by Guzzle's curl handler (ext-curl). They do not bound:
credentials is not set: the SDK then walks its provider chain (env, ini, ECS, IMDS)
outside these timeouts and retries, so a bounded operation time needs static credentials;ext-curl, or the stream request option); with http set,
a missing ext-curl fails the container compilation, and on Guzzle 8+ the stream request option fails the
request (see the CURLOPT_FORBID_REUSE item below);With http set, the client does not follow redirects (allow_redirects: false), and a step in the init step of
the client's handler list turns a 3xx answer into an error that is not retried (one HTTP request):
301 → Aws\S3\Exception\PermanentRedirectException (the SDK's own handling);Define as many storages as you need. Each entity can select which storage to use via the #[Uploadable] attribute:
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.
For files that should not be publicly accessible (contracts, invoices, internal documents), define a storage without a uri_prefix:
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:
#[Uploadable(storage: 'secure', prefix: 'contracts')]
class Contract
{
#[UploadableProperty(mappedBy: 'documentPath')]
private ?File $document = null;
#[ORM\Column(nullable: true)]
private ?string $documentPath = null;
}
A storage can be temporarily disabled without removing its configuration:
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
| 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 |
storages.*.path_style | bool | false | Path-style addressing (LocalStack and some S3-compatible services) |
storages.*.credentials.key | string | — | S3 access key; required when credentials is set. Without credentials the SDK provider chain is used |
storages.*.credentials.secret | string | — | S3 secret key; required when credentials is set |
#[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 |
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.
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 flushBehaviour::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 storageWhen 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:
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'
#[Uploadable(behaviour: Behaviour::Archive, prefix: 'contracts')]
class Contract
{
// Files are archived to var/archive/contracts/ before removal
}
HashingNamingStrategy (default)Generates a unique filename using MD5 hash with random bytes, preserving the guessed file extension:
a1b2c3d4e5f67890abcdef1234567890.pdf
OriginNamingStrategyPreserves 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
Implement NamingStrategyInterface:
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:
#[Uploadable(namingStrategy: TimestampNamingStrategy::class)]
The bundle provides convenience traits for common file/image patterns:
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 |
An entity can have multiple uploadable properties:
#[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;
}
The bundle supports uploadable fields inside Doctrine embeddables:
#[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;
}
Model\File includes ImageTrait with image-specific helpers:
$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
}
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 $entity — the entity object that triggered the event. This allows listeners to filter by entity type.
| 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 carry $entity, $file, and $fieldName. Use PostUploadEvent for post-processing like image resizing:
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 (!$event->entity instanceof Album) {
return;
}
if ($event->file->isImage()) {
$this->resizer->resize($event->file->getPathname(), 1920, 1080);
}
}
}
Removal events carry $entity, $relativePath, $resolvedPath, and $resolvedUri:
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);
}
}
The bundle provides a FileType form type for handling file uploads in Symfony forms. It is a compound form with a file input and an optional delete checkbox that preserves the original Model\File across submissions.
composer require symfony/form symfony/validator
use ChamberOrchestra\FileBundle\Form\Type\FileType;
$builder->add('score', FileType::class);
| Option | Type | Default | Description |
|---|---|---|---|
multiple | bool | false | Allow multiple file uploads |
mime_types | array | [] | Allowed MIME types (sets accept attribute and validation) |
required | bool | false | Whether a file is required |
allow_delete | bool | true | Show a delete checkbox (only when required is false) |
entry_options | array | [] | Options passed to the inner Symfony FileType |
delete_options | array | [] | Options passed to the delete CheckboxType |
$builder->add('score', FileType::class, [
'mime_types' => ['application/pdf'],
'required' => true,
]);
The form view exposes:
original_file — the original Model\File instance (for displaying the current file)allow_delete — whether the delete checkbox is shownmultiple — whether multiple uploads are allowedThe 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:
$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:
// 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"
To serve files through a CDN, set the storage's uri_prefix to the CDN base URL:
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.
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:
location /uploads {
location ~ \.(php|phtml|php[0-9])$ {
deny all;
}
}
Apache (.htaccess in the uploads directory):
<FilesMatch "\.(php|phtml|php[0-9])$">
Require all denied
</FilesMatch>
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.
MIT License. See LICENSE for details.
302307LocationAws\S3\Exception\S3ExceptiongetAwsErrorCode()nullNoSuchKeyisConnectionError()getResponse()getStatusCode()nullgetResult()['@metadata']['statusCode']upload() and download() wrap both in the bundle's RuntimeException. remove() rethrows them unchanged: it
returns false only for NoSuchKey, so a 3xx never reads as a missing object. An endpoint that redirects (for
example a 307 to another host) therefore fails every operation of a storage with http: check that the endpoint
answers without redirects before you set http. A storage without http follows redirects as in 8.1.4.
With http set, the client never reuses a connection (curl: [CURLOPT_FORBID_REUSE => true] in the client's
http argument): every request opens a new TCP (and TLS) connection. Guzzle therefore never re-sends a request on
a dead reused connection, and one SDK attempt is one HTTP request. A storage without http reuses connections as
in 8.1.4.
http requires ext-curl: without it, compiling the container fails with the Error
Undefined constant "CURLOPT_FORBID_REUSE".stream request option fail: Guzzle's
StreamHandler rejects a request with the curl option (InvalidArgumentException).storages.*.http.connect_timeout |
float |
| — |
Connection phase timeout, seconds, finite, >= 0.001; required when http is set (s3 driver only) |
storages.*.http.timeout | float | — | Whole transfer timeout including the connection, seconds, finite, >= 0.001; required when http is set (s3 driver only) |
storages.*.retries.mode | string | — | SDK retry mode: legacy, standard or adaptive; required when retries is set (s3 driver only) |
storages.*.retries.max_attempts | int | — | Total attempts, the first one included, >= 1; required when retries is set (s3 driver only) |
archive_path | string | %kernel.project_dir%/var/archive | Local directory for archived files (Behaviour::Archive) |