If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeThe 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
.
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.
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.
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!
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 | |
} |
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
.
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!
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.
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!
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.
Hey YinYang,
Use the asset() Twig function - it will automatically add that hash at the end of file name for you.
Cheers!
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 !
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!
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 !
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!
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();
}
}
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!
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.
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!
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
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!
Hi Ryan, I think that info about ServiceSubscriber resides in https://symfonycasts.com/sc... not in fundamentals. Cheers :)
// 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
}
}
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 ?