zenstruck/filesystem
Zenstruck Filesystem is a Symfony bundle for attaching files and images to Doctrine entities. It handles storage via Flysystem adapters, image transformations, metadata, and automatic orphan cleanup, keeping file management clean and testable.
Install and enable:
composer require zenstruck/filesystem
The Flex recipe registers ZenstruckFilesystemBundle. If not using Flex, add it to config/bundles.php.
Configure at least one filesystem. The bundle sits on top of Flysystem, so you point it at a Flysystem filesystem service (or let it build one from a local adapter):
# config/packages/zenstruck_filesystem.yaml
zenstruck_filesystem:
filesystems:
default:
flysystem:
adapter: 'local'
config:
directory: '%kernel.project_dir%/var/storage'
For anything non-trivial (S3, GCS, public web roots), define a Flysystem filesystem as a service and reference it here instead.
Map a file to an entity. The core objects are Zenstruck\Filesystem\Doctrine\Mapping\File and ...\Image — Doctrine embeddables that live inside your entity:
use Zenstruck\Filesystem\Doctrine\Mapping\File;
use Zenstruck\Filesystem\Doctrine\Mapping\Image;
#[ORM\Entity]
class Post
{
#[ORM\Embedded(class: File::class, columnPrefix: 'attachment_')]
private ?File $attachment = null;
#[ORM\Embedded(class: Image::class, columnPrefix: 'cover_')]
private ?Image $cover = null;
}
First use case — accept an upload. Inject Zenstruck\Filesystem\Factory\FileFactory / ImageFactory (services zenstruck_filesystem.file_factory, zenstruck_filesystem.image_factory):
$pending = $this->fileFactory->createFromUpload($uploadedFile);
$entity->setAttachment($pending); // PendingFile is stored on flush
$em->flush();
Where to look first:
Zenstruck\Filesystem\File / Image — the readable value objects (path(), name(), size(), mimeType(), url(), contents()).PendingFile — the write-side object you get from the factories.Doctrine\Mapping\* embeddables — these define the persisted shape.zenstruck/filesystem test helpers (InMemoryFilesystem, assertion traits) — most day-to-day correctness lives here.Every write goes through the same three-step flow regardless of source:
$pending = $factory->createFromUpload($uploadedFile); // SplFileInfo / UploadedFile
$pending = $factory->createFromPath('/some/local/path');
$pending = $factory->create($tmpPath);
PendingFile is not persisted; it's a staging object. You either assign it to a File property and let the Doctrine listener run store() on prePersist/preUpdate, or force it with ->store() before assigning if you need the path immediately:
$file = $factory->createFromUpload($uploadedFile)->store();
$entity->setAttachment($file);
Prefer letting the listener store it — that way the file write is tied to the successful flush of the entity.
Assign a new PendingFile to an already-persisted property. The bundle detects the old file was dropped and, with orphan removal enabled, deletes the backing object after flush:
#[ORM\Embedded(class: File::class, columnPrefix: 'attachment_')]
#[AsFile(orphanRemoval: true)]
private ?File $attachment = null;
Use a dedicated command in cron for backlog cleanup (orphans left from failed flushes, deletes done outside Doctrine, etc.). It's the safety net for anything the listeners miss.
Image carries dimensions and integrates with LiipImagine through its Flysystem loader. Configure a loader pointing at the same filesystem, then filter at request time:
liip_imagine:
loaders:
default:
flysystem:
filesystem_service: zenstruck_filesystem.filesystem.default
Serve the filtered variant through a controller rather than expecting a public URL — the underlying storage is not inherently web-visible.
File::url() only works if the filesystem is configured with a public URL generator (local public adapter, S3 with a URL, CDN). Otherwise, stream through a controller:
return $this->file($file->path())->stream($file->name(), ['Content-Type' => $file->mimeType()]);
Keep a "download controller" per app — it lets you add authorization and is the single place to swap storage backends.
The registry lets you route writes by purpose (one bucket for user uploads, one for imported assets, one for tests):
$factory->createFromUpload($file)->toFilesystem('uploads')->store();
Or grab a filesystem directly: $filesystem->filesystem('uploads').
Don't fight the auto-generated hash paths if you can avoid it. When you need control (e.g. users/{id}/avatar.jpg), define a FileMapping/ImageMapping service that implements the naming strategy and reference it from the embeddable mapping — don't set paths by hand on File, they're immutable.
Swap the real filesystem for the in-memory one in config/packages/test/zenstruck_filesystem.yaml. Then assert directly:
$filesystem = self::getContainer()->get('zenstruck_filesystem.filesystem.default');
self::assertFileExists($filesystem, $entity->attachment()->path());
// Or, with Foundry fixtures:
PostFactory::createOne(['attachment' => self::file('fixtures/image.jpg')]);
The factories return PendingFile instances in tests too, so you can skip touching disk for unit tests of entity logic.
columnPrefix is not optional in practice. Because File and Image are embeddables, two properties on the same entity must have distinct prefixes. Missing/duplicate prefixes cause silent column collisions.
PendingFile is not a File. Don't serialize it, store it in a session, or compare it to persisted values. If something needs the path synchronously, call ->store() first.
Orphan removal has two switches. The embeddable mapping and the filesystem config both need to agree. A file left dangling usually means one of them wasn't set (or flush failed after the storage write). When debugging, check the DB row before assuming the config is wrong.
Lifecycle events fire, but not on embeddables. The bundle installs Doctrine listeners; the embeddable itself has no callbacks. Anything that must happen at persist-time (e.g. moving from a temp filesystem) belongs in a listener you register, not on the embeddable.
Entity remove() does not delete files by default. You need to opt into delete-on-remove for the mapping. Decide deliberately: a file referenced by multiple entities (common with Image reuse) must not be auto-deleted.
File is immutable. Setters on your entity replace the object; there's no $file->setName(). This is a feature — it keeps orphans easy to spot (old reference gone → delete candidate). Don't add mutation to the value object.
URL generation requires a URL-aware filesystem. url() returns null for a bare local Flysystem. Configure visibility/url in the Flysystem adapter, or serve via a controller. Don't discover this in production after a CDN swap.
JSON metadata columns grow. The embeddable persists metadata (original name, mime, dimensions). Watch the column length if your adapter records a lot; otherwise your first S3 upload with a deep metadata payload will fail on insert.
Tests should use the in-memory filesystem. Otherwise you'll accumulate junk in var/storage across test runs, and CI will get flaky on parallel tests hitting the same directory. Use a distinct filesystem name for test to guarantee isolation.
Debugging missing files: verify (1) the path returned by ->path(), (2) the Flysystem visibility, (3) whether the adapter root matches your config directory. Nine times out of ten the file exists in storage but the path prefix in the adapter config doesn't match the one embedded in the object.
Extension points worth knowing:
FileMapping / ImageMapping for naming strategy.File.Filesystem decorator wrapping the registry is a clean place to add logging/metrics around uploads.Validate uploads before they reach the bundle. The package stores whatever it's given; it will not reject oversized or wrong-MIME files. Wrap the upload in a Symfony File constraint + Image constraint before calling createFromUpload().
How can I help you explore Laravel packages today?