2lenet/entity-file-bundle
EntityFileBundle lets Symfony entities attach and manage files through configurable FlySystem storage adapters, with simple save, retrieve, access, delete, rename, and move operations via a per-configuration manager.
This is a Symfony bundle, not a Laravel package. Day-to-day use in a Laravel codebase means adopting Doctrine ORM (doctrine/orm + a Doctrine service-provider bridge such as laravel-doctrine/orm) and wiring the bundle's Symfony services manually. If your app is Eloquent-based, stop here and use spatie/laravel-medialibrary instead — the integration cost is not worth it.
Minimal steps:
composer require 2lenet/entity-file-bundleconfig/bundles.php; Laravel: bind Lle\EntityFileBundle\Service\EntityFileLoader and the config in a service provider).config/lle_entity_file.yaml defining one configuration per entity/filesystem pair:
lle_entity_file:
configurations:
seller_logos:
class: "App\\Models\\Seller"
storage_adapter: "lle_entity_file.storage.default"
data/seller_logos; point it elsewhere (S3, FTP, local disk) by defining a new storage in flysystem.yaml and referencing it in storage_adapter.lle_entity_file:
resource: "@LleEntityFileBundle/Resources/config/routes.yaml"
First use case — attach a logo to a Seller:
$manager = $entityFileLoader->get('seller_logos');
$entityFile = $manager->save($seller, $request->file('logo'), 'logo.png');
$this->em->persist($entityFile); // required
$this->em->flush(); // required
Where to look first: the README configuration section, then the Flysystem bundle's storage reference (you'll spend most of your integration time there), then the bundle's EntityFileController to understand how it serves files and enforces the role config.
Config-per-entity is the core abstraction. Treat each configurations entry as a named "file collection" — e.g. seller_logos, product_images, order_reports. Different entities, different storage backends, different roles, different dispositions. Use this to separate public images (S3 public bucket) from private documents (local encrypted disk).
Manager resolution via the loader. Always go through $entityFileLoader->get($configName); don't instantiate managers directly. Inject the loader into controllers, jobs, and console commands.
Controller pattern for uploads:
public function store(Request $request, Seller $seller, EntityFileLoader $loader)
{
$manager = $loader->get('seller_logos');
$file = $manager->save($seller, $request->file('logo'), $request->input('path', 'logo.png'));
$file->setDescription($request->input('description'));
$this->em->persist($file);
$this->em->flush();
return response()->json(['id' => $file->getId()]);
}
Custom EntityFile entity for metadata. If you need description, uploaded_by, checksum, etc., create your own Doctrine entity implementing Lle\EntityFileBundle\Entity\EntityFileInterface, use LleEntityFileBundle\Entity\Trait\EntityFileTrait, and set entity_file_class: "App\\Entity\\UnicornEntityFile" under the config. This is the main extension point — the bundle otherwise stores only the bare minimum.
Dynamic paths. Paths are just strings passed to save(), so build them from entity state:
$dir = $order->getDate()->format('Y-m');
$name = $order->getId() . '.xml';
$manager->save($order, $data, "$dir/$name");
This is the idiomatic way to shard storage by date/tenant/entity, and it avoids needing path logic inside the bundle.
Serving files. Use the two built-in routes for simple cases:
lle_entityfile_entityfile_read — /lle-entity-file/{configName}/{id}lle_entityfile_entityfile_readbypath — /lle-entity-file/{configName}?path=2le.pngFor anything requiring per-record authorization (e.g. "only the seller's owner can see this invoice"), skip the routes and write your own controller using readStream() + StreamedResponse. Set role: "PUBLIC_ACCESS" only for genuinely public assets (product images); leave the default (logged-in users) or a coarse ROLE_OPERATOR for internal files, and use a Symfony voter for finer-grained checks.
Download vs. inline. Set disposition: "attachment" on report/zip configs so browsers download rather than render. Leave inline for images/PDFs you want previewed.
Deletion / cascading. $manager->delete($file) removes both the EntityFile record and the underlying blob. Hook this into Doctrine lifecycle listeners (preRemove) on the owning entity so deleting a Seller cleans up its logos — the bundle does not do this for you.
Storage abstraction. For multi-environment setups, define separate Flysystem storages (local dev, S3 prod, FTP for a legacy vendor drop) and switch via storage_adapter per config, not per-environment conditionals in code.
Crudit integration. If you're in the 2LE stack, drop EntityFileBrickConfig::new('seller_logos') into your tabs to get a free dropzone with list/add/remove/download. This is the fastest path to an admin UI and is the bundle's strongest selling point.
save() does not persist. This is the single most common bug. You must $em->persist($entityFile) and $em->flush() afterwards. The same applies after move() — the manager updates the file on disk but you must flush the entity.config/filesystems.php support. You're using Doctrine's EntityManager and Flysystem's own config, period. If the team can't accept that, pick a different package.data/ may not be writable. The default storage path (data/{config}) breaks in read-only containers and on serverless/stateless deploys. Configure an explicit adapter directory or S3 bucket before going to production.save().logo.png clobbering another's.read() loads the whole file into memory. Use readStream() for large assets or when serving via StreamedResponse.UnableToWriteFile, UnableToReadFile, FileNotFound around save/read/move, and translate them into your API's error format. The bundle delegates exception semantics entirely to Flysystem.role key is a single string check. Anything record-scoped (invoice belonging to a customer) needs a custom Symfony voter; don't try to encode it in YAML./lle-entity-file/... after a clean install.entity_file_class must be set before the first save. Changing it later won't migrate existing rows; plan the schema up front if you expect to add metadata.delete() on a missing blob will throw. Guard deletion paths, especially in bulk cleanup jobs where files may have been removed out-of-band.How can I help you explore Laravel packages today?