Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Value Loader + Preview Template

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

So our content browser was working beautifully... until we selected an item. At that time, it chose to do an odd thing: explode! The Ajax call that failed says:

Value loader for doctrine_recipe value type does not exist.

To review: we have a custom value type called doctrine_recipe, which we created so that we could add grids and lists of Recipe entities. For this to work, we have (1): a value converter to convert Recipe objects into a format understood by layouts. (2) a query type to allow us to use dynamic collections. (3) a browser backend class to allow us to select manual items. And now (4), we need a value loader that is able to take the "id" of these manually-selected items and turn them into Recipe objects. This will be the last "thing" we need for our value type, I promise!

Creating & Tagging the Value Loader

Inside the src/Layouts/ directory, create a new class called RecipeValueLoader, make it implement ValueLoaderInterface and generate the two methods it needs:

... lines 1 - 2
namespace App\Layouts;
use Netgen\Layouts\Item\ValueLoaderInterface;
class RecipeValueLoader implements ValueLoaderInterface
public function load($id): ?object
// TODO: Implement load() method.
public function loadByRemoteId($remoteId): ?object
// TODO: Implement loadByRemoteId() method.

These are pretty simple. But, before we fill them in, go back to the Ajax endpoint, and refresh to see... the exact same error. Why? Like we've seen with other things, we need to "associate" this RecipeValueLoader with our doctrine_recipe value type. How? No surprise! With a tag. Say #[AutoconfigureTag()] and this time it's called netgen_layouts.cms_value_loader. For the second argument, pass value_type set to doctrine_recipe:

... lines 1 - 5
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('netgen_layouts.cms_value_loader', ['value_type' => 'doctrine_recipe'])]
class RecipeValueLoader implements ValueLoaderInterface
... lines 11 - 19

Perfecto! If we reload now... better! That error is because we haven't actually filled in the logic yet.

Adding the Logic

Very simply, we need to take the ID and return the Recipe object. To do that, create a constructor that accepts a RecipeRepository $recipeRepository argument. And... let me clean things up:

... lines 1 - 4
use App\Repository\RecipeRepository;
... lines 6 - 9
class RecipeValueLoader implements ValueLoaderInterface
public function __construct(private RecipeRepository $recipeRepository)
... lines 15 - 24

Now, down here, return $this->recipeRepository->find() and pass $id. For loadByRemoteId(), which we only need if we're using the import feature to move content across databases, just return $this->load($id):

... lines 1 - 9
class RecipeValueLoader implements ValueLoaderInterface
... lines 12 - 15
public function load($id): ?object
return $this->recipeRepository->find($id);
public function loadByRemoteId($remoteId): ?object
return $this->load($remoteId);

And now... the Ajax call works! More importantly, if we refresh the entire layouts admin... yes! Look at our grid! We have four manual items! That is awesome! We can reorder these if we want, add more, remove them, whatever.

Try publishing this page and then reloading the homepage. There they are! Though our "latest recipes" are missing. Whoops! I think I accidentally changed this to a manual collection also. Change that back to a dynamic collection, looks good, publish and.... now... cool: everything is back.

Adding the Preview

So we now have the power to select manual items via the content browser... though when we originally add the config for all of this, we set a preview template... but never created it!

Let's open the content browser again. So on the manual grid, hit "Add items". The preview template powers the preview mode up here. If we click an item, it shows us a preview. Well, it would... except that we haven't actually added that template.

To get this working, we need to do two small things. First, open RecipeBrowserBackend. We skipped a few methods in here. For example, we skipped getSubLocations() and getSubLocationsCount() because those are only need if you have a hierarchy of locations.

We also skipped loadItem(). This is used for the preview. It will pass us the ID of the thing that's loaded and we need to return an ItemInterface. So very simply, we can return a new RecipeBrowserItem() - that's the little class that wraps around the Recipe - passing $this->recipeRepository->find($value):

... lines 1 - 12
class RecipeBrowserBackend implements BackendInterface
... lines 15 - 32
public function loadItem($value): ItemInterface
return new RecipeBrowserItem($this->recipeRepository->find($value));
... lines 37 - 88

Cool! The only other thing we need to do is... actually create the preview template! In templates/nglayouts/, add a new directory called content_browser/, and inside, a new file called recipe_preview.html.twig. To start, just print the dump() function:

The cool thing is, we don't even need to refresh. As long as we click on an item that we haven't already clicked on... it works! And look at this item variable: it's an instance of RecipeBrowserItem... so an instance of this class right here.

That's great... except that RecipeBrowserItem doesn't have a way for us to get the actual Recipe object. Fortunately, we can fix that ourselves. After all, this is our class! I'll go to "Code"->"Generate" to generate a getRecipe() method:

... lines 1 - 7
class RecipeBrowserItem implements ItemInterface
... lines 10 - 33
public function getRecipe(): Recipe
return $this->recipe;

Now, in the template, we can say {{ item.recipe.name }}. And to make this fancier, add an <img whose src is set to item.recipe.imageUrl... also with an alt attribute:

<strong>{{ item.recipe.name }}</strong>
<img src="{{ asset(item.recipe.imageUrl) }}" alt="Recipe Image">

Once again, we don't need to refresh. If you click on an item that you've already previewed, it'll load it from memory. But if you click a new one... yeah! There's our preview! Pretty cool.

Ok, we are done with manual items, the content browser and all of this. By the way, there is a way to add more columns to this table, like filename, file size, created date, etc. We're not going to talk about that, but it's totally possible.

Status check: at this point, we have the ability to add a layout to any page, reorder the content on the page, add title, text, HTML blocks, or even lists and grids of dynamic recipes. That is a lot of power. Now I want more power! I want to make it possible to use the grid and list blocks to add other items to our page... items that do not live in our database at all. That's next.

Leave a comment!

Login or Register to join the conversation
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1.0",
        "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.13", // 2.13.3
        "easycorp/easyadmin-bundle": "^4.4", // v4.4.1
        "netgen/layouts-contentful": "^1.3", // 1.3.2
        "netgen/layouts-standard": "^1.3", // 1.3.1
        "pagerfanta/doctrine-orm-adapter": "^3.6",
        "sensio/framework-extra-bundle": "^6.2", // v6.2.8
        "stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
        "symfony/console": "5.4.*", // v5.4.14
        "symfony/dotenv": "5.4.*", // v5.4.5
        "symfony/flex": "^1.17|^2", // v2.2.3
        "symfony/framework-bundle": "5.4.*", // v5.4.14
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/proxy-manager-bridge": "5.4.*", // v5.4.6
        "symfony/runtime": "5.4.*", // v5.4.11
        "symfony/security-bundle": "5.4.*", // v5.4.11
        "symfony/twig-bundle": "5.4.*", // v5.4.8
        "symfony/ux-live-component": "^2.x-dev", // 2.x-dev
        "symfony/ux-twig-component": "^2.x-dev", // 2.x-dev
        "symfony/validator": "5.4.*", // v5.4.14
        "symfony/webpack-encore-bundle": "^1.15", // v1.16.0
        "symfony/yaml": "5.4.*", // v5.4.14
        "twig/extra-bundle": "^2.12|^3.0", // v3.4.0
        "twig/twig": "^2.12|^3.0" // v3.4.3
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "5.4.*", // v5.4.11
        "symfony/maker-bundle": "^1.47", // v1.47.0
        "symfony/stopwatch": "5.4.*", // v5.4.13
        "symfony/web-profiler-bundle": "5.4.*", // v5.4.14
        "zenstruck/foundry": "^1.22" // v1.22.1