AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 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.檢視完整 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) |