Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Setup for Uploading Private Article References

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

New challenge folks! Our alien authors are begging for a new feature: they want to be able to upload "supporting" files and attach them to the article - like PDFs that they're referencing, images... text notes... really anything. But these files will only be visible to anyone that can edit an article. I'll call these "article references" and every article will be able to have zero to many references, which is where things start to get interesting.

Creating the ArticleReference Entity

Let's create the new entity:

php bin/console make:entity

Call it ArticleReference and give it an article property. This will be a relation back to the Article class. This will be a ManyToOne relation: each Article can have many ArticleReferences. Then, this will be not null in the database: every ArticleReference must be related to an Article. Say yes to map the other side of the relationship - it's convenient to be able to say $article->getArticleReferences(). And no to orphan removal - we won't be using that feature.

Nice! Ok, this needs a few more fields: filename a string that will hold the filename on the filesystem, originalFilename, a string that will hold the original filename that was on the user's system - more on that later - and mimeType - we'll use that to store what type of file it is - which will come in handy later.

And... done! Next run:

php bin/console make:migration

Let's go make sure the migration file doesn't contain any surprises... yep!

CREATE TABLE article_reference

... with a foreign key back to article. Run that with:

php bin/console doctrine:migrations:migrate

Removing Extra Adder/Remover

Before we get back to work, open the Article entity. The command did create the $articleReferences property that allows us to say $article->getArticleReferences(). That's super convenient. It also added addArticleReference() and removeArticleReference(). I'm going to delete these: I'm just not going to need them: I'll read the references from the article, but never set them from this direction.

... lines 1 - 18
class Article
{
... lines 21 - 89
/**
* @ORM\OneToMany(targetEntity="App\Entity\ArticleReference", mappedBy="article")
*/
private $articleReferences;
... line 94
public function __construct()
{
... lines 97 - 98
$this->articleReferences = new ArrayCollection();
}
... lines 101 - 315
/**
* @return Collection|ArticleReference[]
*/
public function getArticleReferences(): Collection
{
return $this->articleReferences;
}
}

Form CollectionType

Ok team: let's think about how we want this to work. The user needs to be able to upload multiple reference files to each article. A lot of you may be expecting me to use Symfony's CollectionType: that's a special field that allows you to embed a collection of fields into a form - like multiple upload fields.

Well... sorry. We are definitely not going to use CollectionType. That field is hard enough to work with if you want to be able to add or delete rows. Adding uploading to that? Oof, that's crazy talk.

We're going to do something different. And it's going to be a much better user experience anyways! We're going to leave the main form alone and build a separate "article reference upload", sort of, "widget", next to it that'll eventually upload via AJAX, allow deleting, editing and re-ordering. It's gonna be schweet!

Adding the HTML Form

Open the edit template: templates/article_admin/edit.html.twig. Everything we're going to do will be inside of this template, not the new template. The reason is simple: trying to upload files to a new entity - something that hasn't been saved to the database - is super hard! You need to store files in a temporary spot, keep track of them, and assign them to the entity when your user does finally save - if they ever do that. So, totally possible - but complex. If you can, have your user fill in some basic data, save your new entity to the database, then show the upload fields.

Anyways, let's add an <hr> and set up a bit of structure: div class="row" and div class="col-sm-8". Say "Details" here and move the entire form inside. Now add a div class="col-sm-4" and say "References".

... lines 1 - 2
{% block content_body %}
<h1>Edit the Article! ?</h1>
<hr>
<div class="row">
<div class="col-sm-8">
<h3>Details</h3>
{{ include('article_admin/_form.html.twig', {
button_text: 'Update!'
}) }}
</div>
<div class="col-sm-4">
<h3>References</h3>
</div>
</div>
{% endblock %}
... lines 20 - 26

Let's see how this looks... nice! Form on the left, upload widget thingy on the right.

Here's the plan: add a <form> tag with the normal method="POST" and enctype="multipart/form-data". Inside, add a single upload field: <input type="file" name="">, how about reference. Then, <button type="submit">, some classes to make it not ugly, and "Upload".

... lines 1 - 2
{% block content_body %}
... lines 4 - 7
<div class="row">
... lines 9 - 14
<div class="col-sm-4">
<h3>References</h3>
<form action="" method="POST" enctype="multipart/form-data">
<input type="file" name="reference">
<button type="submit" class="btn btn-sm btn-primary">Upload</button>
</form>
</div>
</div>
{% endblock %}
... lines 25 - 31

Cool! Yes, we are going to talk about allowing the user to upload multiple files at once. Don't worry, things are going to get much fancier.

Next, let's get the endpoint setup for this upload and store everything in the database, including a few pieces of information about the file that we did not store for the article images.

Leave a comment!

11
Login or Register to join the conversation
Kiuega Avatar

Argh! I would have liked to have tackled the CollectionType! What do you recommend if we really have to use them?

For example, we have a page that allows us to create multiple users at once. And to each user, we have to attach a file (an ID card, an avatar, whatever).

We are therefore forced to use a CollectionType. What is really annoying in this case is that in the controller, we will have to loop through each user to add the associated file.

Worse, if we are in another even less cool context, in which we have a collection with each item which itself contains a collection of files (and which can in the future be modified, deleted, added). It's an ordeal!

Therefore, I was wondering, would it be possible, a bit like with VichUploader, that we create an event that would take care of all the management of the files, alone, without having to do it ourselves? from the controller?

With VichUploader, when we submit a form that contains a file, VichUploader alone manages the addition of the file. Could we not do the same?

EDIT: I found this (old) tutorial from Grafikart (a boss) : https://grafikart.fr/tutori... .It looks pretty good as a mode of operation! Do you think we could use it?

Reply

Hey Kiuega !

I'm still catching up a bit on my messages, as you've probably noticed :).

So... overall... I would once again say: could you possibly... *not* use the CollectionType? It really comes down to the user experience of the page. If you use the CollectionType, then if a user creates 5 users... and uploads 5 avatars... and submits... if there is a validation error on just 1 user, they will lose all 5 uploaded avatars. That's just how non-Ajax upload forms work.

I don't know the exact requirements, but I would make sure that your top-level entity is saved before you get to this page (are these users being added to... like an Organization or something). Then, when you click "add user", you Ajax load a user form and put it on the page. When they hit "add user", you Ajax submit that form & upload. If it's successful, probably you Ajax load an updated "user list" onto the page so the new user shows up there. Stimulus can help do this pretty cleanly. If you were using Turbo (that's a whole other topic), you could probably use a Turbo frame + Turbo Streams to accomplish this. Or just put the "add new user" form onto a totally different page, and leverage Turbo Drive to make the experience feel fast.

So... that's a LONG way of saying: try not to use the CollectionType :). About the tutorial you listed - that may be very good! It certainly looks solid. But it doesn't address the UX problem you'll have with a CollectionType.

Cheers!

1 Reply
Kiuega Avatar

Hi Ryan, thanks for your response! It's very clear ! :) I'll remember it the day I need it!

Regarding the use of an Annotation for the uploadable fields, it is now done!

I set up my annotation, and centralized all the file upload part in a service.

Now, when an entity has the @Uploadable annotation and a File type field has an @UploadableField annotation (with the possibility of designating the properties responsible for containing information such as mapping, public or private visibility, size image, mimeType, or original filename), then I have a listener that takes care of doing whatever it takes, and we can forget about all the code that we ourselves coded in the controllers ! Very convenient ! : D

Reply
Christina V. Avatar
Christina V. Avatar Christina V. | posted 1 year ago

Hi everybody !
In this part of the tutorial:


The reason is simple: trying to upload files to a new entity - something that hasn't been saved to the database - is super hard!

I was wondering, any workflow I could follow to try to make it ? ;)
Thans !

Reply

Hey Christina V.!

Hmm... avoid it if you can? ;)

It simply IS tricky. Well... sort of. Assuming you're not doing anything fancy with AJAX, it's "sort of" simple. In fact, you wouldn't need to make any changes to what we do in this tutorial: you would submit the form and, on success, move the uploaded file around, save the new entity... and everything is happy.

The problem comes when you have validation errors. This is a UX problem: your user uploads a file (hopefully not a big file)... but then the form fails validation because the "email" field (or something) is invalid. When this happens, and the form re-renders, the upload field will be blank. That's annoying - the user has to re-attach their file (if they even notice that it's missing)!

So, to get this right, you need to save the uploaded file... even if the form was unsuccessful.

If you have a separate entity that represents the uploaded file - like ArticleReference - it's a bit easier. When the form submits and is unsuccessful, you would STILL move the uploaded file and create the ArticleReference... but with no related Article. You would then need to take the new id of the ArticleReference and stick it on your form so that when you finally DO submit successfully, you can use that ArticleReference. That's where things get really tricky... specifically with Symfony's form system. I might even skip the form system, and instead pass the new ArticleReference id into the template and render it as a hidden input by hand. Then, on submit (when it's successful) you could read that hidden POST parameter by hand, query for the ArticleReference, and set the Article object on it before saving.

Also, you will probably want some recurring process (e.g. a CRON job) that deletes all the ArticleReference objects in the database that have no related Article and are older than 24 hours. This will "clean out" uploaded files where the user never completed the form successfully.

Phew! So not impossible... just tricker ;). Avoid it if you can. Sometimes that's easy. For example, you could have an "Article Creation Wizard" where on step 1, you ask for the article title. On submit, you save the article to the database. Then, on step 2, you show many more fields and the upload.

Cheers!

Reply
Christina V. Avatar
Christina V. Avatar Christina V. | weaverryan | posted 1 year ago

Well.. some huge thanks, you made everything really clear!
Just for the fun I'll probably try the hard and tricky one.
But I did a kind of wizard last night, with form in 2 steps. The design is still ugly (wait for it) but it works pretty good!

Thank you very much for your explanations!

Reply

Woooh! Nice work! Let me know how the hard way goes :)

Reply
Рафаил Д. Avatar
Рафаил Д. Avatar Рафаил Д. | posted 3 years ago

Why do you use make:migration instead of doctrine:migrations:diff ?

Reply

Hey Рафаил Д.

There is no difference between both commands, it's just for convenience. If you want you can see where the idea came from :)
https://github.com/symfony/...

Cheers!

Reply
Ad F. Avatar

the code block for edit template is incomplete :)

Reply

hey Ad F.

Thank you for your report, we investigate the problem and soon everything will be fixed.

Stay on tuned!

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