Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filesystem Laravel Package

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.

Deep Wiki
Context7

Getting Started

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.
  • The bundle's Doctrine\Mapping\* embeddables — these define the persisted shape.
  • zenstruck/filesystem test helpers (InMemoryFilesystem, assertion traits) — most day-to-day correctness lives here.

Implementation Patterns

Factory → PendingFile → stored File

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.

Replacing a file (orphan removal)

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.

Images and LiipImagine

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.

Serving files

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.

Multiple filesystems

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').

Deterministic paths with mapping

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.

Testing

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.

Gotchas and Tips

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:

  • Custom FileMapping / ImageMapping for naming strategy.
  • Custom file factory decorators for validation (MIME allow-lists, size limits) — do this in the controller or a Symfony validator, not by subclassing File.
  • A 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().

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource