Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Flysystem <3 LiipImagineBundle

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Flysystem is killing it for us! But... there's a problem hiding... like, a it-won't-actually-work-in-the-real-world kind of problem. Yikes! In theory, we should be able to go into the oneup_flysystem.yaml file right now, change the adapter to S3 and everything would work. In theory.

How LiipImagineBundle Finds Images

The problem is LiipImagineBundle. Open up templates/article/homepage.html.twig: we call uploaded_asset(), pass that article.imagePath and that value is passed into imagine_filter. So basically, a string like uploads/article_image/something.jpg is passed to the filter.

The problem? By default, LiipImagineBundle reads the source image file from the filesystem. If we refactored to use S3... well... imagine would be looking in the wrong place!

You can see this by running:

php bin/console debug:config liip_imagine

This is the current config for this bundle, which includes all of its default values. Near the bottom, see that "loaders" section? The "loader" is the piece that's responsible for reading the source image. It defaults to using the filesystem and it knows to look in the public/ directory! So when we pass it upload/article_image/ some filename, it finds it perfectly. Well... it works until our files don't live on the server anymore.

The solution? We need this to use Flysystem.

Flysystem Loader

Let's go to back to the LiipImagineBundle documentation: find their GitHub page and then click down here on the "Download the Bundle" link as an easy way to get into their full docs. Now, go back to the main page and... down here near the bottom, it talks about different "data loaders". The default is "File System", we want Flysystem.

Let's see... yea, we've already installed the bundle. Copy this loaders section - we already have our Flysystem config all set up. Then, open our liip_imagine.yaml file and, really, anywhere, paste!

This creates a loader called profile_photos - that name can be anything. Let's use flysystem_loader. The critical part is the key flysystem: that says to use the "Flysystem" loader that comes with the bundle. The only thing it needs to know, is the service id of the filesystem that we want to use.

... lines 2 - 5
... lines 9 - 57

For that, go back to config/services.yaml and copy the long service id from the bind section. Back in liip_imagine.yaml, paste!

... lines 2 - 5
filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem
... lines 10 - 57

We now have a "loader" called flysystem_loader, and a "loader's" job is to... ya know, "load" the source file. You can technically have multiple loaders, though I've never had to do that. To always have LiipImagineBundle load the files via Flysystem, below, add data_loader set to the loader's name: flysystem_loader. I'll add a comment:

default loader to use for all filter sets

... lines 2 - 5
filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem
# default loader to use for all filter sets
data_loader: flysystem_loader
... lines 13 - 57

Because, you can technically specify which loader you want to use on each filterset. Again, I've never had to do that: we always want to use flysystem.

Cool! Let's try it! Go into the public/ directory... let me find it... and delete all the existing thumbnails - let's delete media/cache/ entirely. By doing this, the bundle will use the data loader to get the contents of each image so that it can recreate the thumbnails.

Correcting the Path to LiipImagineBundle

Testing time! Let's go back to, how about, the homepage. And... it doesn't work. Drat! Inspect element. Hmm, it does start with the media/cache/resolve part. Then, the path at the end is - uploads/article_image/lightspeed...png. That's the path that we're passing to the filter.

Go back to the homepage template. The problem now - and it's really cool - is that we told LiipImagineBundle to use Flysystem to load files... but the root of our filesystem is the public/uploads directory. In other words, if you want to read a file from our filesystem, the path needs to be relative to this directory. In other words, it should not contain the uploads/ part

The fix? Remove the uploaded_asset() function: we can just pass article.imagePath, which will be article_image/ the filename.

... lines 1 - 2
{% block body %}
<div class="container">
<div class="row">
... lines 6 - 8
<div class="col-sm-12 col-md-8">
... lines 10 - 21
<div class="article-container my-1">
<a href="{{ path('article_show', {slug: article.slug}) }}">
<img class="article-img" src="{{ article.imagePath|imagine_filter('squared_thumbnail_small') }}">
... lines 25 - 37
... line 40
... lines 42 - 61
{% endblock %}

I love this! Need to thumbnail something? Just pass it the Flysystem path: you don't need the word uploads or anything like that. The uploaded_asset() function will still be useful if you want the public path to an asset without thumbnailing, but if you're using imagine_filter, passing the short, relative path is all you need.

Try it! Refresh! It still doesn't work? Oh yea! A few minutes ago, we deleted all of the original images from the fixtures. But we did re-upload a few of them. So if you scroll down... here we go - here's the Earth image we uploaded. So, it is now working perfectly.

Let's reload our fixtures to make sure:

php bin/console doctrine:fixtures:load

Now the homepage... yes - everything is here. Let's make the same change in the other two places we're thumbnailing. Click onto the show page. This lives in templates/article/show.html.twig: remove uploaded_asset there. Refresh... good!

... lines 1 - 4
{% block content_body %}
<div class="row">
<div class="col-sm-12">
<img class="show-article-img" src="{{ article.imagePath|imagine_filter('squared_thumbnail_medium') }}">
... lines 9 - 25
... lines 28 - 78
{% endblock %}
... lines 80 - 86

For the other one, go back to the admin article section - log back in with password "engage", because we reloaded the database. When we're editing an image, yep, also broken.

Find this in templates/article_admin/_form.html.twig: take off uploaded_asset().

{{ form_start(articleForm) }}
... lines 2 - 5
<div class="row">
... lines 7 - 13
<div class="col-sm-3">
{% if articleForm.vars.data.imageFilename %}
<img src="{{ articleForm.vars.data.imagePath|imagine_filter('squared_thumbnail_small') }}" height="100">
{% endif %}
... lines 20 - 38
{{ form_end(articleForm) }}

And... got it!

The Resolver: Saving the Images to Flysystem

So, the "data loader" is responsible for reading the original image. But, there's another important concept from LiipImagineBundle called "resolvers". Click down on the "Flysystem Resolver" in their docs. The resolver is responsible for saving the thumbnail image back to the filesystem after all of the transformations. By default, no surprise, LiipImagineBundle writes things directly to the filesystem. So even if we moved Flysystem to s3, LiipImagineBundle would still be writing the thumbnail files back to our server - into the public/media directory.


You can also completely offload the processing and storage of your files to a cloud service like rokka.io by leveraging LiipRokkaImagineBundle.

Let's change that! In the docs, copy the resolvers section. Back in our liip_imagine.yaml file, paste that. It's pretty much the same as before: we'll call it flysystem_resolver and tell it to save the images using the same filesystem service. Remove visibility - that sets the Flysystem visibility, which is a concept we'll talk about soon. True is the default value anyways, which basically means these files will be publicly accessible.

... lines 2 - 13
filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem
cache_prefix: media/cache
root_url: /uploads
... lines 21 - 67

cache_prefix is the subdirectory within the filesystem where the files should be stored and root_url is the URL that all the paths will be prefixed with when the image paths are rendered. Right now, it needs to be /uploads.

For example, if LiipImagineBundle stores a file called media/cache/foo.jpg into Flysystem, we know that the public path to this will be /uploads/media/cache/foo.jpg. We'll talk more about this setting later when we move to s3.

Ok, delete the media/ directory entirely. Oh, and I almost forgot the last step: add cache set to flysystem_resolver - let's put an "r" on that.

... lines 2 - 13
filesystem_service: oneup_flysystem.public_uploads_filesystem_filesystem
cache_prefix: media/cache
root_url: /uploads
# default cache resolver for saving thumbnails
cache: flysystem_resolver
... lines 23 - 67

This tells the bundle to always use this resolver. I'm not sure why it's called "cache" - the bundle seems to use "resolver" and "cache" to describe this one concept.

Ok! Moment of truth! Refresh. Ha! It works! Go check out where the thumbnails are stored: there is no media/ directory anymore! The Flysystem filesystem points to the public/uploads directory, so the media/cache directory lives there. And thanks to the /uploads root_url, when it renders the path, it knows to start with /uploads and then the path in Flysystem.

I love this! It's a bit tricky to get these two libraries to play together perfectly. But now we are much more prepared to switch between local uploads and S3.

Next: we can generate public URLs to thumbnailed files or the original files. But, what if you need to force all the URLs to include the domain name? This is something you don't think about until you need to generate a PDF or send an email from a console command or worker. Then... it can be a nightmare. Let's add this to our asset system in a way that we love.

Leave a comment!

This tutorial is built on Symfony 4 but works great in Symfony 5!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "aws/aws-sdk-php": "^3.87", // 3.87.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.1
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.9.0
        "league/flysystem-aws-s3-v3": "^1.0", // 1.0.22
        "league/flysystem-cached-adapter": "^1.0", // 1.0.9
        "liip/imagine-bundle": "^2.1", // 2.1.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.1.0
        "oneup/flysystem-bundle": "^3.0", // 3.0.3
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.4
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.2.3
        "symfony/console": "^4.0", // v4.2.3
        "symfony/flex": "^1.9", // v1.17.6
        "symfony/form": "^4.0", // v4.2.3
        "symfony/framework-bundle": "^4.0", // v4.2.3
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.2.3
        "symfony/serializer-pack": "^1.0", // v1.0.2
        "symfony/twig-bundle": "^4.0", // v4.2.3
        "symfony/validator": "^4.0", // v4.2.3
        "symfony/web-server-bundle": "^4.0", // v4.2.3
        "symfony/yaml": "^4.0", // v4.2.3
        "twig/extensions": "^1.5" // v1.5.4
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.1.0
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.2.3
        "symfony/dotenv": "^4.0", // v4.2.3
        "symfony/maker-bundle": "^1.0", // v1.11.3
        "symfony/monolog-bundle": "^3.0", // v3.3.1
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.2.3
        "symfony/profiler-pack": "^1.0", // v1.0.4
        "symfony/var-dumper": "^3.3|^4.0" // v4.2.3