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!

  • 2020-06-23 Zool

    Hey Vladimir Sadicov ,

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

  • 2020-06-23 Vladimir Sadicov

    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!

  • 2020-06-22 Zool

    Hey Vladimir Sadicov ,

    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">
    {{ article.title }}


    <img class="article-author-img rounded-circle" src="{{ asset('images/alien-profile.png') }}"> {{ article.author }}

    {{ article.publishedAt ? article.publishedAt|ago : 'unpublished' }}


    {{ article.heartCount }}



    {% for tag in article.tags %}
    {{ tag.name }}
    {% endfor %}

    </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">

    Share:


    </div>
    </div>
    <div class="row">
    <div class="col-sm-12">
    <h3>{{ 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">
    Amy Oort
    <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">
    {{ comment.authorName }}
    <small>about {{ comment.createdAt|ago }}</small>
    {% if comment.isDeleted %}
    deleted
    {% endif %}


    {{ comment.content }}

    Reply


    </div>
    </div>
    </div>
    {% endfor %}

    </div>
    </div>

    {% endblock %}

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

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

    and MarkdownHelper.php class


    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();
    }
    }
  • 2020-06-22 Vladimir Sadicov

    Hey Zool

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

    Cheers!

  • 2020-06-22 Zool

    Hi Victor Bocharsky,
    Thanks a lot that cleared my doubt.

  • 2020-06-22 Victor Bocharsky

    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!

  • 2020-06-21 Zool

    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:

    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

    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
    ->get(MarkdownHelper::class)->parse($value);
    }

    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 !

  • 2020-06-20 Zool

    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.

  • 2020-01-27 Vladimir Sadicov

    Hi Camille Seuvin

    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!

  • 2020-01-24 Camille Seuvin

    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

  • 2019-03-04 weaverryan

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

  • 2019-03-04 Sargath

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