Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

URL to Public Assets

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

The hardest part of handling uploads... probably isn't the uploading part! For me, it's rendering the URLs to the uploaded files, thumbnailing and creating endpoints to download private files. Oh, and we gotta keep this organized: I do not want a bunch of upload directory names sprinkled over 50 files in my code. It's bad for sanity, I mean, maintenance, and will make it hard to move your uploads to the cloud later... which we are going to do.

Look back at the homepage: all of these images work except for one. But, this is actually the image that we uploaded! Inspect element on that and check its path: /images/astronaut-blah-blah.jpeg. Check out one of the working images. Ah yes: until now, in the fixtures, we set the $imageFilename string to one of the filenames that are hardcoded and committed into the public/images/ directory, like asteroid.jpeg.

These aren't really uploaded assets: we were just faking it! Check out the template: templates/article/homepage.html.twig. There it is! We're using the asset()... ah, wrong spot. Here we go: we're saying {{ asset(article.imagePath) }}, which calls getImagePath() inside Article. That just prefixes the filename with images/ and returns it! So if imageFilename is asteroid.jpeg in the database, this returns images/asteroid.jpeg.

Pointing the Path to uploads/

Now that the true uploaded assets are stored in a different directory, we can just update this path! In Article, change this to uploads/article_image/ and then $this->getImageFilename().

... lines 1 - 17
class Article
{
... lines 20 - 184
public function getImagePath()
{
return 'uploads/article_image/'.$this->getImageFilename();
}
... lines 189 - 307
}

Cool! Try it out! It works! We don't care about the broken images from the fixtures: we'll fix them soon. But the actual uploaded image does render.

Getting Organized

Great first step. Now, let's get organized! One problem is that we have the directory name - article_image - in Article and also in UploaderHelper where we move the file around. That's not too bad - but as we start adding more file uploads to the system, we're going to have more duplication. I don't like having these important strings in multiple places.

So, in UploaderHelper, why not create a constant for this? Call it ARTICLE_IMAGE and set it to the directory name: article_image.

... lines 1 - 7
class UploaderHelper
{
const ARTICLE_IMAGE = 'article_image';
... lines 12 - 32
}

Down below, use that: self::ARTICLE_IMAGE.

... lines 1 - 7
class UploaderHelper
{
... lines 10 - 18
public function uploadArticleImage(UploadedFile $uploadedFile): string
{
$destination = $this->uploadsPath.'/'.self::ARTICLE_IMAGE;
... lines 23 - 31
}
}

And in Article, do the same thing: UploaderHelper::ARTICLE_IMAGE.

... lines 1 - 5
use App\Service\UploaderHelper;
... lines 7 - 18
class Article
{
... lines 21 - 185
public function getImagePath()
{
return 'uploads/'.UploaderHelper::ARTICLE_IMAGE.'/'.$this->getImageFilename();
}
... lines 190 - 308
}

Small step, and when we refresh, it works fine.

Centralizing the Public Path

Let's keep going! Back in Article, the path starts with uploads... because that's part of the public path to the asset. That's not a huge problem, but I actually don't want that uploads string to live here. Why? Well, I kinda don't want my entity to really care where or how we're storing our uploads. Like, if our site grows and we move our uploads to the cloud, we would need to change this uploads string to a full CDN URL in all entities with an upload field. And, that URL might even need to be dynamic - we might use a different CDN locally versus on production! Nope, I don't want my entity to worry about any of these details.

Remove the uploads/ part from the path.

... lines 1 - 18
class Article
{
... lines 21 - 185
public function getImagePath()
{
return UploaderHelper::ARTICLE_IMAGE.'/'.$this->getImageFilename();
}
... lines 190 - 308
}

Now getImagePath() returns the path to the image relative to wherever our app decides to store uploads. In UploaderHelper, add a new public function getPublicPath(). This will take a string $path - that will be something like article_image/astronaut.jpeg - and it will return a string, which will be the actual public path to the file. Inside, return 'uploads/'.$path;.

... lines 1 - 7
class UploaderHelper
{
... lines 10 - 33
public function getPublicPath(string $path): string
{
return 'uploads/'.$path;
}
}

That may feel like a micro improvement, but it's awesome! Thanks to this, we can call getPublicPath() from anywhere in our app to get the URL to an uploaded asset. If we move to the cloud, we only need to change the URL here! Awesome!

uploaded_asset() Twig Extension

Except... how can we call this from Twig? Because, if we refresh right now... it definitely does not work. No worries: let's create a custom Twig function. Open src/Twig/AppExtension - this is the Twig extension we created in our Symfony series. Here's the plan: in the homepage template, instead of using the asset() function, let's use a new function called uploaded_asset(). We'll pass it article.imagePath - and it will ultimately call getPublicPath().

... lines 1 - 20
{% for article in articles %}
... lines 22 - 23
<img class="article-img" src="{{ uploaded_asset(article.imagePath) }}">
... lines 25 - 39
{% endfor %}
... lines 41 - 65

In AppExtension, copy getFilters(), paste and rename it to getFunctions(). Return an array, and, inside, add a new TwigFunction() with uploaded_asset and [$this, 'getUploadedAssetPath'].

... lines 1 - 10
use Twig\TwigFunction;
... line 12
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 15 - 21
public function getFunctions(): array
{
return [
new TwigFunction('uploaded_asset', [$this, 'getUploadedAssetPath'])
];
}
... lines 28 - 56
}

Copy that new method name, scroll down and add it: public function getUploadedAssetPath() with a string $path argument. It will also return a string.

... lines 1 - 12
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 15 - 42
public function getUploadedAssetPath(string $path): string
{
... lines 45 - 47
}
... lines 49 - 56
}

Using a Service Subscriber

Inside: we need to get the UploaderHelper service so we can call getPublicPath() on it. Normally we do this by adding it as an argument to the constructor. But, in a few places in Symfony, for performance purposes, we should do something slightly different: we use what's called a "service subscriber", because it allows us to fetch the services lazily. If this is a new concept for you, go check out our Symfony Fundamentals course - it's a really cool feature.

The short explanation is that this class has a getSubscribedServices() method where we can choose which services we need. These are then included in the $container object and we can fetch them out by saying $this->container->get().

Add UploaderHelper::class to the array.

... lines 1 - 5
use App\Service\UploaderHelper;
... lines 7 - 12
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 15 - 49
public static function getSubscribedServices()
{
return [
... line 53
UploaderHelper::class,
];
}
}

Then, above, we can return $this->container->get(UploaderHelper::class)->getPublicPath($path).

... lines 1 - 12
class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
... lines 15 - 42
public function getUploadedAssetPath(string $path): string
{
return $this->container
->get(UploaderHelper::class)
->getPublicPath($path);
}
... lines 49 - 56
}

Let's give it a try! Refresh! We got it! That took some work, but I promise you'll be super happy you did this.

Next: let's also update the image path in the show page, and learn a bit about what the asset() function does internally and how we can do the same thing automatically in UploaderHelper.

Leave a comment!

22
Login or Register to join the conversation
Loqman S. Avatar
Loqman S. Avatar Loqman S. | posted 1 year ago

Hello ! :-)

Why I need to use '/uploads/' . $path when you just use 'uploads/' . $path? Why do I need that starting slash to make it work ?

Reply

Hey Loqman

That's because we are passing our image path to the custom Twig function we've just created uploaded_asset(). In the next chapter you'll learn more about how it works internally

Cheers!

Reply
Loqman S. Avatar

Thanks for your reply, literally the entire next video is the answer to my question :D

1 Reply

NP! Happy to help :)

Reply

Lets say I have an application that manages the students in a college starting from admission all the way to the time they graduate. Such an application might require that the images (Profile Photo, Id documents, credentials etc) of each student stored in a directory that is named after the student or more appropriately the registration number of the student vsay 2021RC0004 . Is there a way to create a directory on the file system on the fly (by passing the student registration number in the POST payload) and move the file to this newly created directory e.g. public/uploads/2021RC0004? The entity can return the public path thst doesn't include the student registration number public/uploads/ the UI can concatenate the student registration number to obtain the complete directory path.

Reply

Hey sridharpandu

Yes, that's possible, you could leverage the Filesystem Symfony component to do that task. It's a nice abstraction for working the filesystem
https://symfony.com/doc/cur...

Cheers!

Reply
Vicc V. Avatar
Vicc V. Avatar Vicc V. | posted 2 years ago | edited

Hi, I've got an issue implementing that with webpack encore, I make a dump on ($this->container->get(UploaderHelper::class)->getPublicPath($path) and I saw the result is :

"uploads/provider/avatar/homme-60798628bf9b3.jpg"

So I added "build" on the beginning of the path in the getPublicPath method :

return '/build/uploads/'.$path

But it doesn't work because the unique id of the file is missing, my file on my server looks like that :

homme-60798628bf9b3.60aa3ab6.jpg

How can I fix that ? Thank you.

Reply

Hey YinYang,

Use the asset() Twig function - it will automatically add that hash at the end of file name for you.

Cheers!

Reply
Kiuega Avatar

Hi SymfonyCasts team! Thanks for this tutorial! However, I have a question (I haven't watched the sequel yet so it may be unnecessary?).

Why not just use the VichUploader bundle? It is so useful, easy to use, it would have saved a lot of time on a lot of aspects.

Maybe you didn't highlight it to show us how everything works, rather than going the easy way? Which would be totally logical. But talking about this bundle could be nice too! (Again I'm only at this chapter so maybe you'll talk about it later!)

Thank you for everything !

Reply

Hey Kiuega!

Yes, very fair question! I would say it's 2 major things:

1) Yes, I do like showing HOW things work... though that is not by itself a good enough reason to not use a library in a tutorial.

2) VichUploaderBundle - which I used to *love* - just feels a bit *too* magic to me. It is probably something that would work really well for most people, but I find that when you need to start doing more custom stuff, it's really unclear how to do extend. I prefer having more control.

That being said, in a perfect world, we'd probably have something "in between". And if you do need to do some uploading, giving VichUploaderBundle an hour to test out is a good idea in case it works really nicely for you.

Cheers!

1 Reply
Raed Avatar
Raed Avatar Raed | posted 3 years ago | edited

Hi team,
Symfony is not happy about this line ->get(MarkdownHelper::class)->parse($value); in processMarkdown()
when i click on an article to show it, i got this error:

<blockquote>Argument 1 passed to App\Service\MarkdownHelper::parse() must be of the type string, null given, called in C:\xampp\htdocs\SymfonyCasts\Symfony4\AllaboutUplFiles\01_start\src\Twig\AppExtension.php on line 47
</blockquote>

When i comment it, it works, but without the makrdown text

AppExtension class


namespace App\Twig;

use App\Service\MarkdownHelper;
use App\Service\UploaderHelper;
use Psr\Container\ContainerInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;

class AppExtension extends AbstractExtension implements ServiceSubscriberInterface
{
    private $container;
    public function __construct(ContainerInterface $container)
    {
        $this->container = $container;
    }
    
    public function getFunctions(): array
    {
        return [
            new TwigFunction('uploaded_asset', [$this, 'getUploadedAssetPath'])
        ];
    }

    public function getFilters(): array
    {
        return [
            new TwigFilter('cached_markdown', [$this, 'processMarkdown'], ['is_safe' => ['html']]),
        ];
    }

    public function processMarkdown($value)
    {
        return $this->container
           <b> ->get(MarkdownHelper::class)->parse($value);</b>
    }
    
    public function getUploadedAssetPath(string $path): string
    {
        return $this->container
            ->get(UploaderHelper::class)
            ->getPublicPath($path);
    }

    public static function getSubscribedServices()
    {
        return [
            MarkdownHelper::class,
            UploaderHelper::class,
        ];
    }
}

I appreciate your help !

Reply
sadikoff Avatar sadikoff | SFCASTS | Raed | posted 3 years ago | edited

Hey Raed

Losks like your $value is null somehow, check your template, how do you use this filter, and which value you pass to it

Cheers!

Reply
Raed Avatar
Raed Avatar Raed | sadikoff | posted 3 years ago | edited

Hey ``sadikoff ,

Thanks for your reply !
Losks like your $value is null somehow, mmm, i don't think so because when i click to show an article with no photo
it gets rendered,

Here is show.html.twig code:


{% extends 'content_base.html.twig' %}

{% block title %}Read: {{ article.title }}{% endblock %}

{% block content_body %}
    <div class="row">
        <div class="col-sm-12">
            <img class="show-article-img" src="{{ uploaded_asset(article.imagePath) }}">
            <div class="show-article-title-container d-inline-block pl-3 align-middle">
                <span class="show-article-title ">{{ article.title }}</span>
                <br>
                <span class="align-left article-details"><img class="article-author-img rounded-circle" src="{{ asset('images/alien-profile.png') }}"> {{ article.author }} </span>
                <span class="pl-2 article-details">
                    {{ article.publishedAt ? article.publishedAt|ago : 'unpublished' }}
                </span>
                <span class="pl-2 article-details">
                    <span class="js-like-article-count">{{ article.heartCount }}</span>
                    <a href="{{ path('article_toggle_heart', {slug: article.slug}) }}" class="fa fa-heart-o like-article js-like-article"></a>
                </span>
                <span class="pl-2 article-details">
                    {% for tag in article.tags %}
                        <span class="badge badge-secondary">{{ tag.name }}</span>
                    {% endfor %}
                </span>
            </div>
        </div>
    </div>
    <div class="row">
        <div class="col-sm-12">
            <div class="article-text">
                {{ article.content|cached_markdown }}
            </div>
        </div>
    </div>
    <div class="row">
        <div class="col-sm-12">
            <p class="share-icons mb-5"><span class="pr-1">Share:</span> <i class="pr-1 fa fa-facebook-square"></i><i class="pr-1 fa fa-twitter-square"></i><i class="pr-1 fa fa-reddit-square"></i><i class="pr-1 fa fa-share-alt-square"></i></p>
        </div>
    </div>
    <div class="row">
        <div class="col-sm-12">
            <h3><i class="pr-3 fa fa-comment"></i>{{ article.nonDeletedComments|length }} Comments</h3>
            <hr>

            <div class="row mb-5">
                <div class="col-sm-12">
                    <img class="comment-img rounded-circle" src="{{ asset('images/astronaut-profile.png') }}">
                    <div class="comment-container d-inline-block pl-3 align-top">
                        <span class="commenter-name">Amy Oort</span>
                        <div class="form-group">
                            <textarea class="form-control comment-form" id="articleText" rows="1"></textarea>
                        </div>
                        <button type="submit" class="btn btn-info">Comment</button>
                    </div>
                </div>
            </div>

            {% for comment in article.nonDeletedComments %}
            <div class="row">
                <div class="col-sm-12">
                    <img class="comment-img rounded-circle" src="{{ asset('images/alien-profile.png') }}">
                    <div class="comment-container d-inline-block pl-3 align-top">
                        <span class="commenter-name">{{ comment.authorName }}</span>
                        <small>about {{ comment.createdAt|ago }}</small>
                        {% if comment.isDeleted %}
                            <span class="fa fa-close"></span> deleted
                        {% endif %}
                        <br>
                        <span class="comment"> {{ comment.content }}</span>
                        <p><a href="#">Reply</a></p>
                    </div>
                </div>
            </div>
            {% endfor %}

        </div>
    </div>

{% endblock %}

{% block javascripts %}
    {{ parent() }}

    <script src="{{ asset('js/article_show.js') }}"></script>
{% endblock %} 

and MarkdownHelper.php class


<?php

namespace App\Service;

use Michelf\MarkdownInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\Security\Core\Security;
 
class MarkdownHelper
{
    private $cache;
    private $markdown;
    private $logger;
    private $isDebug;

    private $security;

    public function __construct(AdapterInterface $cache, MarkdownInterface $markdown, LoggerInterface $markdownLogger, bool $isDebug, Security $security)
    {
        $this->cache = $cache;
        $this->markdown = $markdown;
        $this->logger = $markdownLogger;
        $this->isDebug = $isDebug;
        $this->security = $security;
    }

    public function parse(string $source): string
    {
        if (stripos($source, 'bacon') !== false) {
            $this->logger->info('They are talking about bacon again!', [
                'user' => $this->security->getUser()
            ]);
        }

        // skip caching entirely in debug
        if ($this->isDebug) {
            return $this->markdown->transform($source);
        }

        $item = $this->cache->getItem('markdown_'.md5($source));
        if (!$item->isHit()) {
            $item->set($this->markdown->transform($source));
            $this->cache->save($item);
        }

        return $item->get();
    }
}
Reply
sadikoff Avatar sadikoff | SFCASTS | Raed | posted 3 years ago | edited

Hey hey

You know...mmm.. my answer will not change, the only reason I see is null value inside article.content variable, so you can check it in Twig by using {{ dump() }} or inside AppExtension using dd() or dump

Cheers!

Reply
Raed Avatar
Raed Avatar Raed | sadikoff | posted 3 years ago | edited

Hey sadikoff ,

I appreciate your help ! By just re-loading the fixtures again , it works !

Reply
Raed Avatar

Hi team !
Where the getFunctions() name in twig extension come from ?
I tried to rename it to something else but i got an error as : Unknown "uploaded_asset" function.

public function getFunctions(): array
{
return [
new TwigFunction('uploaded_asset', [$this, 'getUploadedAssetPath'])
];
}

Thanks for your awesome courses.

Reply

Hey Raed.

That getFunctions() comes from the class you extends. So, it's required to have exactly that name so that Twig could register functions in your project. Otherwise, it won't be able to register the functions you listed there, e.g. uploaded_asset in your case. In other words, that method name should not be changed. But you can rename that "uploaded_asset" function name or associated "getUploadedAssetPath()" if needed. But if you change its name - make sure you change it in the whole project.

Cheers!

Reply
Raed Avatar
Raed Avatar Raed | Victor | posted 3 years ago | edited

Hi victor,
Thanks a lot that cleared my doubt.

Reply

Hello, thank you for this great course.

I followed everything well and everything works so far.

I did not follow the other courses so I did not have "Twig/AppExtension.php", I created this class and the function {{uploaded_asset ()}} works in twig but does not return the $path ( 'uploads/').

Need to add a configuration somewhere?

Thank you

Reply

Hi ojtouch

Have you downloaded course code, like it was mentioned in First chapter? It doesn't return path because it's rely on another service, which probably not fully configured. It's hard to say something without seeing code :)

Cheers!

Reply
Sargath Avatar
Sargath Avatar Sargath | posted 4 years ago

Hi Ryan, I think that info about ServiceSubscriber resides in https://symfonycasts.com/sc... not in fundamentals. Cheers :)

Reply

Ah, you're right! Thanks for the correction - we'll add a note!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

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
    }
}
userVoice