Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Rethinking File Handling in Symfony

Avatar for Kevin Bond Kevin Bond
June 12, 2025
230

Rethinking File Handling in Symfony

Let’s be honest - handling files in Symfony isn’t the smoothest experience, especially when it comes to attaching them to Doctrine entities.

In this talk, we’ll break down the current state of file handling in Symfony and some of the common challenges developers face. Then, I’ll introduce a new package that simplifies file storage, integrates seamlessly with your app, and makes adding files to your Doctrine entities a breeze.

If you’ve ever found file handling in Symfony frustrating, this session will introduce new ways to make it easier and more maintainable.

Avatar for Kevin Bond

Kevin Bond

June 12, 2025
Tweet

Transcript

  1. Me? From Ontario, Canada Husband, father of three Symfony user

    since 1.0 Symfony Core/UX Team @kbond on GitHub/Slack @zenstruck on Twitter @zenstruck.com on BlueSky
  2. zenstruck? A GitHub organization where my open source packages live

    zenstruck/foundry zenstruck/browser zenstruck/messenger-test zenstruck/console-test zenstruck/messenger-monitor-bundle ... Many co-maintained by Nicolas PHILIPPE ( @nikophil )
  3. What we'll cover Two file handling scenarios: file explorer controller

    user profile image upload Functional tests 3 different approaches Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  4. Approach 1: Symfony Core symfony/filesystem Manage local files (copy, move,

    delete, etc.) symfony/finder Find/filter local files/directories local: can be used with PHP stream wrappers for s3, ftp, etc. Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  5. Approach 1a - File Explorer Controller #[Route('/file-explorer', name: 'app_file_explorer')] public

    function index( Request $request, #[Autowire('%kernel.project_dir%/public/files')] string $rootDir, ): Response { // ... } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  6. Approach 1a - File Explorer Controller $path = $request->query->getString('path'); $dir

    = new \SplFileInfo(sprintf('%s/%s', $rootDir, $path)); // !!! /** @var \SplFileInfo[] $nodes */ $nodes = Finder::create() ->in($dir) ->sortByType() ->depth(0) ; // ... Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  7. Approach 1a - File Explorer Controller // ... return $this->render('file_explorer/index.html.twig',

    [ 'nodes' => $nodes, 'path' => array_filter(explode('/', $path)), ]); Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  8. Approach 1a - File Explorer Template {% for node in

    nodes %} {% if node.isDir %} {# ... #} {% else %} <a href="/files/{{ path|merge([node.filename])|join('/') }}"> {{ node.filename }} </a> {% endif %} {{ node.size }} {{ node.type }} {{ node.mTime|date('Y-m-d H:i:s') }} {% endfor %} Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  9. Approach 1a - File Explorer Test public function test_explore_files(): void

    { $this->browser() ->visit('/file-explorer') ->assertSuccessful() ->assertSee('our-processes.txt') // ... ; } "root directory" is hardcoded files must pre-exist Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  10. Approach 1b - Image Upload Controller #[Route('/user', name: 'app_user')] public

    function index( Request $request, EntityManagerInterface $em, #[Autowire('%kernel.project_dir%/public/files/avatars')] string $avatarsDirectory, ): Response { $user = $this->getUser(); $form = $this->createForm(AvatarForm::class); $form->handleRequest($request); // ... } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  11. Approach 1b - Image Upload Controller if ($form->isSubmitted() && $form->isValid())

    { /** @var UploadedFile $file */ $file = $form->get('image')->getData(); $filename = sprintf('%s.%s', $user->getUsername(), $file->getClientOriginalExtension() ); $file->move($avatarsDirectory, $filename); $user->setAvatarImage($filename); $em->flush(); return $this->redirectToRoute('app_user'); } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  12. Approach 1b - Image Upload Template {% if user.avatarImage %}

    <img src="/files/avatars/{{ user.avatarImage }}" id="avatar-image"> {% endif %} {{ form(form) }} Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  13. Approach 1b - Image Upload Test public function test_can_upload_image(): void

    { UserFactory::createOne(['username' => 'kbond']); $expectedFile = self::AVATAR_IMAGE_DIR.'/kbond.jpg'; $this->assertFileDoesNotExist($expectedFile); $fileToUpload = self::FILE_FIXTURE_DIR.'/kevin.jpg' // navigate and upload image $this->assertFileExists($expectedFile); } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  14. Approach 1 Summary Hardcoded directories local files only Orphaned files

    on entity delete Public urls are a pain to generate Testing requires cleanup Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  15. Approach 2: league/flysystem A filesystem abstraction library Defacto standard for

    PHP league/flysystem-bundle for Symfony integration Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  16. Approach 2 - Bundle Configuration # config/packages/flysystem.yaml flysystem: storages: public.filesystem:

    adapter: 'local' public_url: /files options: directory: '%kernel.project_dir%/public/files' avatars.filesystem: adapter: 'local' public_url: /files/avatars options: directory: '%kernel.project_dir%/public/files/avatars' Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  17. Approach 2a - File Explorer Controller #[Route('/file-explorer', name: 'app_file_explorer')] public

    function index( Request $request, #[Target('public.filesystem')] FilesystemReader $filesystem, ): Response { // ... } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  18. Approach 2a - File Explorer Controller $path = $request->query->getString('path'); /**

    @var StorageAttributes[] $nodes */ $nodes = $filesystem->listContents($path); // no security concern return $this->render('file_explorer/index.html.twig', [ 'nodes' => $nodes, 'filesystem' => $filesystem, 'path' => array_filter(explode('/', $path)), ]); Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  19. Approach 2a - File Explorer Template {% for node in

    nodes %} {% if node.isDir %} {# ... #} {% else %} <a href="{{ filesystem.publicUrl(node.path) }}"> {{ node.path|split('/')|last }} </a> {% endif %} {{ node.fileSize }} {{ node.type }} {{ node.lastModified|date('Y-m-d H:i:s') }} {% endfor %} Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  20. Approach 2b - Image Upload Controller #[Route('/user', name: 'app_user')] public

    function index( Request $request, EntityManagerInterface $em, #[Target('avatars.filesystem')] FilesystemOperator $filesystem, ): Response { // ... } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  21. Approach 2b - Image Upload Controller if ($form->isSubmitted() && $form->isValid())

    { // ... // $file->move($avatarsDirectory, $filename); $filesystem->write($filename, $file->getContent()); // ... } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  22. Approach 2b - Image Upload Controller return $this->render('user/index.html.twig', [ 'user'

    => $user, 'form' => $form, 'filesystem' => $filesystem, // !!! ]); Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  23. Approach 2b - Image Upload Template {% if user.avatarImage %}

    <img src="{{ filesystem.publicUrl(user.avatarImage) }}" id="avatar-image"> {% endif %} {{ form(form) }} Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  24. Approach 2 Summary Filesystem abstraction FTW Need to pass path

    and filesystem around together StorageAttributes are only available when listing and do not have access to everything (ie publicUrl) Orphaned files on entity delete Testing is easier Adjust configured filesystems with when@test Manual cleanup still required Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  25. Approach 3 - zenstruck/filesystem Library I've been working on for

    years Uses league/flysystem under the hood Alternate API with File / Directory objects league/flysystem is like the Doctrine DBAL zenstruck/filesystem is like the Doctrine ORM Lots of testing helpers! composer require zenstruck/filesystem Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  26. Approach 3 - The Filesystem /** @var \Zenstruck\Filesystem $filesystem */

    $filesystem->has('some/path'); // bool $filesystem->copy('from/file.txt', 'dest/file.txt'); $filesystem->move('from/file.txt', 'dest/file.txt'); $filesystem->delete('some/file.txt'); $filesystem->mkdir('some/directory'); $filesystem->chmod('some/file.txt', 'private'); $filesystem->write('some/path.txt', 'content'); // or resource or \SplFileInfo Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  27. Approach 3 - The Filesystem /** @var \Zenstruck\Filesystem $filesystem */

    $file = $filesystem->file('some/file.txt'); // Zenstruck\Filesystem\File $dir = $filesystem->directory('some/dir'); // Zenstruck\Filesystem\Directory Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  28. Approach 3 - File|Directory Objects /** @var \Zenstruck\Filesystem\Node $node */

    $node->path()->toString(); $node->path()->name(); $node->path()->basename(); $node->path()->extension(); $node->path()->dirname(); $node->directory(); // ?Directory $node->visibility(); // ie "public" or "private" $node->lastModified(); // \DateTimeImmutable Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  29. Approach 3 - File Object /** @var \Zenstruck\Filesystem\Node\File $file */

    $file->contents(); // string - the file's contents $file->read(); // resource $file->size(); // int $file->checksum(); // string $file->publicUrl(); // string (needs to be configured) $file->temporaryUrl('+30 minutes'); // string (needs to be configured) $file->tempFile(); // \SplFileInfo Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  30. Approach 3 - Directory Object /** @var Zenstruck\Filesystem\Node\Directory<Node> $directory */

    $directory->files(); // Directory<File> $directory->files()->recursive(); $directory->files() ->olderThan('20 days ago') ->largerThan('10MB') ; Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  31. Approach 3 - Bundle Configuration # config/packages/zenstruck_filesystem.yaml zenstruck_filesystem: filesystems: public:

    dsn: '%kernel.project_dir%/public/files' public_url: /files avatars: dsn: 'scoped:public:avatars' Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  32. Approach 3a - File Explorer Controller #[Route('/file-explorer', name: 'app_file_explorer')] public

    function index( Request $request, #[Target('public.filesystem')] Filesystem $filesystem, ): Response { // ... } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  33. Approach 3a - File Explorer Controller $path = $request->query->getString('path'); /**

    @var Directory|Node[] $directory */ $directory = $filesystem->directory($path); return $this->render('file_explorer/index.html.twig', [ 'directory' => $directory, ]); Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  34. Approach 3a - File Explorer Template {% for node in

    directory %} {% if node.isDirectory %} {# ... #} {% else %} <a href="{{ node.publicUrl }}"> {{ node.path.name }} </a> {% endif %} {{ node.size }} {{ node.mimeType }} {{ node.lastModified|date('Y-m-d H:i:s') }} {% endfor %} Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  35. Approach 3b - Image Upload Controller #[Route('/user', name: 'app_user')] public

    function index( Request $request, EntityManagerInterface $em, ): Response { // ... } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  36. Approach 3b - Image Upload Controller if ($form->isSubmitted() && $form->isValid())

    { /** @var File $file */ $file = $form->get('image')->getData(); $user->setAvatarImage($file); $em->flush(); // ... } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  37. Approach 3b - AvatarForm public function buildForm(FormBuilderInterface $builder, array $options):

    void { $builder ->add('image', PendingFileType::class); ; } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  38. Approach 3b - User Entity #[StoreAsPath( filesystem: 'avatars', namer: 'expression:{this.username}{ext}',

    )] private ?File $avatarImage = null; Doctrine Event Listeners Save to filesystem on persist Delete from filesystem on delete/set null Remove old files when setting a new file Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  39. Approach 3 - Testing Configuration composer require --dev league/flysystem-memory #

    config/packages/zenstruck_filesystem.yaml when@test: zenstruck_filesystem: filesystems: public: dsn: static-in-memory Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  40. Approach 3 - FixtureFilesystemProvider ...extends KernelTestCase implements FixtureFilesystemProvider { public

    function createFixtureFilesystem(): string { return __DIR__.'/Fixtures/files'; } } composer require --dev league/flysystem-read-only Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  41. Approach 3 - InteractsWithFilesystem class MyTest extends KernelTestCase { use

    InteractsWithFilesystem; public function test_something() { $this->filesystem(); // "TestFilesystem" } } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  42. Approach 3 - File Explorer Test public function test_explore_files(): void

    { $this->filesystem() ->copy( 'fixture://our-processes.txt', 'public://our-processes.txt' ); // ... } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  43. Approach 3 - Image Upload Test public function test_can_upload_image(): void

    { UserFactory::createOne(['username' => 'kbond']); $this->filesystem() ->assertNotExists('avatars://kbond.jpg'); $fileToUpload = $this->filesystem() ->realFile('fixture://kevin.jpg'); // navigate and upload image } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  44. Approach 3 - Image Upload Test public function test_can_upload_image(): void

    { // ... $this->filesystem() ->assertExists('avatars://kbond.jpg', function (TestFile $file) { $file ->assertSize(13964) ->assertMimeTypeIs('image/jpeg') ; }); } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  45. Cache Busting # config/packages/zenstruck_filesystem.yaml zenstruck_filesystem: filesystems: public: # ... version:

    true /path/to/file.jpg?v=1234567890 (timestamp) Can customize (size/checksum) Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  46. Cache Busting Performance # config/packages/zenstruck_filesystem.yaml zenstruck_filesystem: filesystems: public: # ...

    version: true cache: metadata: last_modified Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  47. Store more on the Entity #[StoreWithMetadata( metadata: ['last_modified', 'size'], filesystem:

    'avatars', namer: 'expression:{this.username}{ext}', )] private ?File $avatarImage = null; json column in the database Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  48. File Streaming use Zenstruck\Filesystem\Symfony\HttpFoundation\FileResponse; /** @var \Zenstruck\Filesystem\File $file */ return

    FileResponse::attachment($file); return FileResponse::inline($file); Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  49. Zip Files \SplFileInfo & Zenstruck\Filesystem league/flysystem-ziparchive use Zenstruck\Filesystem\Archive\ZipFile; $archive =

    new ZipFile('/local/path/to/archive.zip'); $archive->file('some/file.txt'); // \Zenstruck\Filesystem\Node\File $archive->write('another/file.txt', 'content'); Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  50. Archive Response use Zenstruck\Filesystem\Symfony\HttpFoundation\ArchiveResponse; return ArchiveResponse::zip($what, 'my-archive.zip'); $what : File

    , Directory , \SplFileInfo (for file or dir) Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  51. More Features! Image Manipulation/Urls Uses Imagine/Glide or raw GDImage/ImageMagick Serializer

    Support Temporary/Signed URLs Logging Events WDT Panel Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  52. zenstruck/filesystem Flysystem Checksum Readonly Adapter Public URL improvements ZipArchive Adapter

    improvements Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  53. Image File $image = $filesystem->image('some/image.jpg'); /** @var \Zenstruck\Filesystem\Node\File\Image $image */

    $image->dimensions()->height(); // int $image->dimensions()->width(); // int $image->dimensions()->aspectRatio(); // float $image->dimensions()->isSquare(); // bool $image->exif(); // array $image->iptc(); // array $image->thumbHash(); // (requires srwiez/thumbhash) Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond
  54. Image Manipulation if ($form->isSubmitted() && $form->isValid()) { /** @var PendingImage

    $file */ $file = $form->get('image')->getData(); $file->transformInPlace( function(ImageInterface $image) { return $image->thumbnail(new Box(75, 75)); }, ['quality' => 75], ); // ... } Rethinking File Handling Kevin Bond • @zenstruck • github.com/kbond