Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Entity Methods & Twig Magic

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 $12.00

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

Login Subscribe

Our VinylMix entity has a $votes integer property... but we're not printing that on the page... just yet. Let's do that. Over in templates/vinyl/browse.html.twig, after createdAt, add a line break and print mix.votes... (which even autocompleted for us)! If we float over and refresh... nice! We see the votes, which can be positive or negative because, alas, the Internet can apparently be an unfriendly place!

The Built-in Repository Methods

Right now, we're querying the database and the results are coming back in whatever order the database wants. Could we order these by the highest votes first? Sure! One option is to write a custom query inside of VinylMixRepository, which we'll learn about soon. But these repository classes have several methods that allow us to, at least, do some basic stuff!

For example, we can call findAll()... or we could call find() and pass it an ID to find a single VinylMix. And there are others, like findOneBy() or findBy(), where you pass it an array of criteria to use in a WHERE clause. For example, we could find all mixes WHERE name equals some value.

But for this situation, leave that criteria empty so it returns everything. Why? Because I want to leverage the second argument: the "order". Pass an array with 'votes' => 'DESC'.

... lines 1 - 11
class VinylController extends AbstractController
{
... lines 14 - 38
public function browse(VinylMixRepository $mixRepository, string $slug = null): Response
{
... lines 41 - 42
$mixes = $mixRepository->findBy([], ['votes' => 'DESC']);
... lines 44 - 48
}
}

And now... nice! The highest votes are first!

Adding a Custom Entity Method

Ok, so votes can be positive or negative. To make that super obvious, I want to print a plus sign in front of the positive votes. We could do that by adding some logic in Twig. But remember, we have this nice entity class! Sure, right now it only has getter and setter methods. But we are allowed to add our own custom methods. And that's a great way to organize your code.

Check it out: create a new public function called, how about getVotesString(), which will return a 🥝. I'm kidding, it'll return a string of course. Then calculate the "+" or "-" prefix with some fancy logic that says:

If the votes are equal to zero, we want no prefix. If the votes are greater than zero, we want a plus symbol. Else we want a minus symbol.

And... let me surround this entire second statement in parenthesis. This is probably the fanciest line of code I've ever written... which also means it's the most confusing! Feel free to break this onto multiple lines.

... lines 1 - 9
class VinylMix
{
... lines 12 - 116
public function getVotesString(): string
{
$prefix = ($this->votes === 0) ? '' : (($this->votes >= 0) ? '+' : '-');
... lines 120 - 121
}
}

At the bottom, return sprintf() with %s, which will be the prefix, and %d, which will be the vote count. Pass these in: $prefix then the absolute value of $this->votes... since we're adding the negative sign in manually.

... lines 1 - 9
class VinylMix
{
... lines 12 - 116
public function getVotesString(): string
{
... lines 119 - 120
return sprintf('%s %d', $prefix, abs($this->votes));
}
}

We can now use this nice method anywhere in our app... like from inside a template with mix.getVotesString(). Or shorten this to mix.votesString.

... lines 1 - 2
{% block body %}
... lines 4 - 28
{% for mix in mixes %}
... line 30
<div class="mixed-vinyl-container p-3 text-center">
... lines 32 - 39
{{ mix.votesString }} votes
</div>
... line 42
{% endfor %}
... lines 44 - 46
{% endblock %}

Twig is smart enough to realize that votesString is not a real property... but that there is a getVotesString() method. And so, it will call that. Think of this as a virtual property inside of Twig.

If we fly back over and refresh... awesome! We get the minus and plus signs.

A Second Custom Entity Method!

While we're here, the broken images - caused by the placeholder site I'm using being down - are... kind of annoying! Time to fix those!

In a real app, we'll probably let our users upload real images... though for now, we'll stick with dummy images. But either way, we'll probably need the ability to get the URL to a vinyl mix's image from multiple places in our code. To make that easy and keep the code centralized, let's add another entity method!

How about public function getImageUrl(). Give this a $width argument so we can ask for different sizes. Inside I'll paste in some code that uses a different service for dummy images. This looks a bit fancy - but I'm just trying to use the id to get a predictable, but random image... skipping the first 50, which are all nearly identical on this site.

... lines 1 - 9
class VinylMix
{
... lines 12 - 123
public function getImageUrl(int $width): string
{
return sprintf(
'https://picsum.photos/id/%d/%d',
($this->getId() + 50) % 1000, // number between 0 and 1000, based on the id
$width
);
}
}

Anyways, now we have this nice reusable method!

Back in the template... up here is where I have the hardcoded image URL. Replace this with mix.imageUrl(), but this time, we do need to pass an argument. Pass 300... and let's update the alt attribute as well to Mix album cover.

... lines 1 - 2
{% block body %}
... lines 4 - 28
{% for mix in mixes %}
... lines 30 - 31
<img src="{{ mix.getImageUrl(300) }}" alt="Mix album cover">
... lines 33 - 42
{% endfor %}
... lines 44 - 46
{% endblock %}

If we go over and refresh... lovely. Our mixes have images!

Cleanup: Deleting the Old Repository

Ok one last tiny cleanup thing. We no longer need this MixRepository service, which loads mixes from GitHub. Let's delete it so I don't get confused... since its name is so similar to the new VinylMixRepository. Right click on MixRepository.php, go to "Refactor", and click on "Safe Delete".

Easy! But... we might still be using that somewhere, right? If you go to your terminal and run:

git grep MixRepository

that'll show you where it's still being mentioned.

Though, Symfony's service container is so smart, it will often tell us if we've messed something up, like if we're still using a service that doesn't exist. Watch. Try refreshing any page. Yup!

Cannot autowire service App\Command\TalkToMeCommand: argument $mixRepository of method __construct() has type App\Service\MixRepository.

Even though this page doesn't even use the TalkToMeCommand class, it figured out that there's a problem with it. Open it up: src/Command/TalkToMeCommand.php. Yep! We were using MixRepository... so that we could call its findAll() method. Change that to use VinylMixRepository... and then we can remove the use statement on top. The VinylMixRepository still has a findAll() method, so this will still work. This isn't a very efficient way to find a random mix, but it's good enough for now.

... lines 1 - 4
use App\Repository\VinylMixRepository;
... lines 6 - 17
class TalkToMeCommand extends Command
{
public function __construct(
private VinylMixRepository $mixRepository
)
... lines 23 - 55
}

Ok, close that class and go refresh again. The service container found another problem spot in VinylController! Head over there and... up in the constructor... yep! We're autowiring it here too. But... we're not even using the property anymore, so remove it. Also delete its use statement and a couple of other use statements that are not being... uh... used anymore more.

... lines 1 - 4
use App\Repository\VinylMixRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use function Symfony\Component\String\u;
class VinylController extends AbstractController
{
public function __construct(
private bool $isDebug
)
... lines 16 - 47
}

And now... the site works again!

Next, let's learn how to build custom queries via the query builder!

Leave a comment!

4
Login or Register to join the conversation
Markchicobaby Avatar
Markchicobaby Avatar Markchicobaby | posted 9 months ago | edited

What do you think, should the second ternary operator have the zero comparison?

Old
$prefix = ($this->votes === 0) ? '' : (($this->votes >= 0) ? '+' : '-');

Suggested
$prefix = ($this->votes === 0) ? '' : (($this->votes > 0) ? '+' : '-');

This is because the zero case should be taken care of by the first conditional, hence the >=0 should only be checking for >0.

Leaving the >=0 appears to make it look like the zero case is an option to create + prefix.

M

Reply

Hey Markchicobaby,

It makes sense to keep only "> 0" there to avoid adding "+" prefix to 0, agree. :) That's not that much important for learning purposes, so it's written this way in the tutorial. But you can improve it if you want to whatever you l ike more ;)

Cheers!

Reply
davidmintz Avatar
davidmintz Avatar davidmintz | posted 11 months ago | edited

OMFG!! I never knew git has its own grep. I've been saying things like grep -r some_string src/* for years. My life just became a little bit easier.

Reply

Lol - You're welcome :)

Reply
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.7", // v3.7.0
        "doctrine/doctrine-bundle": "^2.7", // 2.7.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.12", // 2.12.3
        "knplabs/knp-time-bundle": "^1.18", // v1.19.0
        "pagerfanta/doctrine-orm-adapter": "^3.6", // v3.6.1
        "pagerfanta/twig": "^3.6", // v3.6.1
        "sensio/framework-extra-bundle": "^6.2", // v6.2.6
        "stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
        "symfony/asset": "6.1.*", // v6.1.0
        "symfony/console": "6.1.*", // v6.1.2
        "symfony/dotenv": "6.1.*", // v6.1.0
        "symfony/flex": "^2", // v2.2.2
        "symfony/framework-bundle": "6.1.*", // v6.1.2
        "symfony/http-client": "6.1.*", // v6.1.2
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/proxy-manager-bridge": "6.1.*", // v6.1.0
        "symfony/runtime": "6.1.*", // v6.1.1
        "symfony/twig-bundle": "6.1.*", // v6.1.1
        "symfony/ux-turbo": "^2.0", // v2.3.0
        "symfony/webpack-encore-bundle": "^1.13", // v1.15.1
        "symfony/yaml": "6.1.*", // v6.1.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.4.0
        "twig/twig": "^2.12|^3.0" // v3.4.1
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "6.1.*", // v6.1.0
        "symfony/maker-bundle": "^1.41", // v1.44.0
        "symfony/stopwatch": "6.1.*", // v6.1.0
        "symfony/web-profiler-bundle": "6.1.*", // v6.1.2
        "zenstruck/foundry": "^1.21" // v1.21.0
    }
}
userVoice