Verify the bundle actually installs before designing around it. This is a 1-star, 0-dependent package targeting Symfony2, so the first "step" is a 10-minute spike:
composer require youshido/uploadable --dev # or clone the repo and path-repo it
If Composer refuses on modern PHP (likely — Symfony2-era constraints usually cap at php: >=5.5,<7.2), don't fight it. Clone the repo, read the whole thing (it is small), and decide whether to path-require it, vendor it into src/, or port the logic. Reading DependencyInjection/Configuration.php and the Doctrine event subscriber is the real "where to look first" — those two files define every config key and the exact lifecycle hooks. The README on a package this small is frequently stale relative to the code.
Registration is the standard Symfony2 dance:
// app/AppKernel.php
$bundles = [
// ...
new Youshido\UploadableBundle\YoushidoUploadableBundle(),
];
Then configure a storage location. The key names below are the shape — confirm against Configuration.php:
# app/config/config.yml
youshido_uploadable:
storage:
local:
upload_path: "%kernel.root_dir%/../web/uploads"
Annotate an entity with a class-level marker and a property-level marker on a non-persisted file property (the bundle moves the file during Doctrine lifecycle events and writes the resulting filename into a separate, persisted column):
/**
* @Uploadable\Uploadable
*/
class Product
{
/** @ORM\Column(type="string", nullable=true) */
private $imageName;
/** @Uploadable\UploadableFile */
private $imageFile; // no @ORM\Column
public function setImageFile(\Symfony\Component\HttpFoundation\File\UploadedFile $file) { ... }
public function getImageFile() { ... }
public function setImageName(string $name) { ... }
}
First use case: a form with an image/PDF field on an entity — handleRequest() → persist() → flush() should move the file to web/uploads and persist the generated name. Build exactly that one flow end-to-end before anything else; if it doesn't work, the package isn't worth further investment.
The unmapped-form-field pattern. The file property must be excluded from form mapping, or Symfony will complain that the property doesn't exist on the entity:
$builder->add('imageFile', FileType::class, [
'mapped' => false,
'required' => false,
'constraints' => [new File(['maxSize' => '2M', 'mimeTypes' => ['image/jpeg', 'image/png']])],
]);
Uploads outside of forms. Because the work happens in a Doctrine prePersist/preUpdate listener, anything that flushes works — console commands, fixtures, API endpoints. Construct an UploadedFile from a temp path (e.g. new UploadedFile($path, $name, null, null, true) with the test flag) and set it on the entity before flush(). Do not try to call the bundle's services directly; the public API is the entity setter plus flush().
Wrap it in your own service. With 1 star and 0 dependents, treat this as unmaintained. Put a thin FileStorage service in your app that owns the store/replace/delete logic and delegates to the bundle. When you inevitably swap to vich/uploader-bundle, you change one class instead of every controller.
Reusable form type.
class ImageUploadType extends AbstractType
{
public function buildForm(FormBuilderInterface $b, array $o): void
{
$b->add('imageFile', FileType::class, ['mapped' => false, 'required' => false]);
}
}
Functional tests. Override upload_path in config_test.yml to point at %kernel.cache_dir%/uploads and assert on both sides:
$client->request('POST', '/products', ['product' => [...]], ['product' => ['imageFile' => $file]]);
self::assertFileExists($dir.'/'.$product->getImageName());
Use UploadedFile fixtures generated in setUp() (a 1x1 PNG byte string is enough) and clean the temp dir in tearDown() so tests don't leak files.
Bulk import. Loop, set the file, $em->persist() each entity, but flush in batches — the listener runs per-flush, so batch flushing keeps I/O and memory predictable.
preUpdate silently doesn't fire. Doctrine's preUpdate only runs when the entity's change set is non-empty. Since the file property is unmapped, changing only the file means no update event — the file is never moved. Mitigation: always touch a mapped field when replacing a file ($this->updatedAt = new \DateTime();).preRemove. Every delete or file replacement leaves a stale file on disk. Add your own Doctrine preRemove listener, or a nightly reconciler that diffs the directory against imageName values in the DB.getClientOriginalName(). It's attacker-controlled. Generate the stored name yourself (UUID/hash + whitelisted extension) and validate MIME in the form constraint, not in the entity.web/ for sensitive files. Public-served uploads should be non-executable (php_flag engine off / nginx location block). If documents are private, store in var/ and stream via a controller with an access check.%kernel.root_dir%; on Symfony 4+ that parameter is gone (use %kernel.project_dir%). If you're grafting it onto a modern kernel, expect to rewrite the config default.@Uploadable annotations, app/console cache:clear --env=prod — otherwise the compiled annotation metadata is stale and your entity silently isn't registered.app/console container:debug --tag=doctrine.event_subscriber (S2) and check that the Uploadable class annotation is actually present on the entity — missing the class-level marker while having the property-level one is the most common misconfiguration.composer.json ("youshido/uploadable": "dev-master#<sha>") rather than a branch alias, or vendor the source into your repo so a force-push can't break your build.vich/uploader-bundle, which solves all of the above (namers, preRemove cleanup, directory mapping per entity, Symfony 4–7 support) and is the pragmatic default unless you specifically need this package's exact annotation surface.How can I help you explore Laravel packages today?