Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Adding Lists: Value Type

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.

We have a Recipe entity and, on the frontend, a page that lists the recipes. We also saw how easy it is to create a layout, which instantly makes parts of this page configurable.

Adding Lists of Existing Content via Layouts?

But now, looking at the homepage, I'm wondering if we can add more complex blocks, beyond just text. Could we, for example, add a block that renders a list of recipes? Something similar to what we have here right now... except instead of adding this via a Twig block, it's added entirely via layouts by an admin user? And, to go further, could we even let the admin user choose which recipes to show here?

Totally! If the first big idea of Layouts is allowing Twig template blocks to be rearranged and mixed with dynamic content, then the second big idea is allowing pieces of existing content - like recipes from our database - to be embedded onto our page by admin users.

How? Edit the Homepage Layout. In the blocks on the left, check out this one called "Grid". Add that after our "Hero" Twig block. The Grid allows us to add individual items to it... which could be anything. But, I don't see a way to do that!

Ok, so we know that a lot of blocks, like titles, maps, markdown, etc can be added to our pages in layouts out-of-the-box with no extra setup work. But the purpose of some blocks - like List, Grid, and the Gallery blocks down here (which are just fancy grids that have JavaScript behavior attached to them) - is to render a collection of "items" that are loaded from somewhere else, like our local database, CMS, or even your Sylius store. The "things" or "items" we can add to these blocks are called "value types". And... we currently have zero. If this were a Sylius project, we could install the Sylius and Layouts integration and instantly be able to select products. The same is true if you're using Ibexa CMS.

Adding a Value Type

So here's our next big goal: to add our Recipe Doctrine entity as a "value type" in layouts so that we can create lists and grids containing recipes.

Step one to adding a value type is to tell Layouts about it in a config file. Over in config/packages/netgen_layouts.yaml, very simply, say value_types, and below that, doctrine_recipe . This is the internal name of the value type, and we'll refer to it in a few places. Give it a human-friendly name - Recipe - and for now, set manual_items to false... and make sure that has an "s" on the end:

netgen_layouts:
... lines 2 - 3
value_types:
doctrine_recipe:
name: Recipe
manual_items: false

We'll talk about manual_items more later, but it's easier to set this to false to start.

Head over, refresh our layouts page (it's okay to reload it)... and check out our Grid block! There's a new "Collection type" field and "Manual collection" is our only option right now. So... this still doesn't seem to be working. I can't change this to anything else... and I also can't select items manually.

Dynamic vs Manual Queries

There are two ways to add items to one of these "collection" blocks. The first is a dynamic collection where we choose from a pre-made query. We could choose a "Most Popular" query that would query for the most popular recipes or a "latest recipes" query, to give two examples. The second way to choose items is manually: the admin user literally selects which they want from a list.

Adding a Query Type

We're going to start with the first type: the dynamic collection. We don't see "Dynamic collection" as an option yet because we need to create one of those pre-made queries first. Those pre-made queries are called query_types. We could, for example, create a query type for Recipe called "Most Popular" and another one called "Latest".

How do we create those? Head back to the config file, add query_types and below that, let's say latest_recipes. Once again, this is just an "internal name". Also give it a human-readable name: Latest Recipes:

netgen_layouts:
... lines 2 - 8
query_types:
latest_recipes:
name: 'Latest Recipes'

So... what do we do now? If we head back and refresh... we get a very nice error that tells us what to do next:

Query type handler for latest_recipes query type does not exist.

It's trying to tell us that we need to build a class that represent this query type! Let's do it!

The Query Type Handler Class

Over in the src/ directory, I'm going to create a new Layouts/ directory: we'll organize a lot of our custom Layouts stuff inside here. Then add a new PHP class called... how about LatestRecipeQueryTypeHandler. Make this implement QueryTypeHandlerInterface:

... lines 1 - 2
namespace App\Layouts;
... lines 4 - 5
use Netgen\Layouts\Collection\QueryType\QueryTypeHandlerInterface;
... lines 7 - 8
class LatestRecipeQueryTypeHandler implements QueryTypeHandlerInterface
{
... lines 11 - 29
}

Then go to "Code Generate" (or Command+N on a Mac), and select "Implement methods" to add the four we need:

... lines 1 - 4
use Netgen\Layouts\API\Values\Collection\Query;
... line 6
use Netgen\Layouts\Parameters\ParameterBuilderInterface;
class LatestRecipeQueryTypeHandler implements QueryTypeHandlerInterface
{
public function buildParameters(ParameterBuilderInterface $builder): void
{
// TODO: Implement buildParameters() method.
}
public function getValues(Query $query, int $offset = 0, ?int $limit = null): iterable
{
// TODO: Implement getValues() method.
}
public function getCount(Query $query): int
{
// TODO: Implement getCount() method.
}
public function isContextual(Query $query): bool
{
// TODO: Implement isContextual() method.
}
}

Nice! Let's see... I'll leave buildParameters() empty for a minute, but we'll come back to it soon:

... lines 1 - 9
class LatestRecipeQueryTypeHandler implements QueryTypeHandlerInterface
{
... lines 12 - 15
public function buildParameters(ParameterBuilderInterface $builder): void
{
}
... lines 19 - 40
}

The most important method is getValues(). This is where we'll load and return the "items". If our recipes were stored on an API, we would make an API request here to fetch those. But since they're in our local database, we'll query for them.

To do that, go to the top of the class, add a __construct() method with private RecipeRepository $recipeRepository:

... lines 1 - 4
use App\Repository\RecipeRepository;
... lines 6 - 9
class LatestRecipeQueryTypeHandler implements QueryTypeHandlerInterface
{
public function __construct(private RecipeRepository $recipeRepository)
{
}
... lines 15 - 40
}

Then, down in getValues(), return $this->recipeRepository... and use a method that I already created inside of RecipeRepository called ->createQueryBuilderOrderedByNewest(). Also add ->setFirstResult($offset) and ->setMaxResults($limit). The admin user will be able to choose how many items to show and they can even skip some. And so, Layouts passes us those values as $limit and $offset... and we use them in our query. Finish with ->getQuery() and ->getResult():

... lines 1 - 9
class LatestRecipeQueryTypeHandler implements QueryTypeHandlerInterface
{
... lines 12 - 19
public function getValues(Query $query, int $offset = 0, ?int $limit = null): iterable
{
return $this->recipeRepository->createQueryBuilderOrderedByNewest()
->setFirstResult($offset)
->setMaxResults($limit)
->getQuery()
->getResult();
}
... lines 28 - 40
}

Perfect! Below, for getCount(), let's do the exact same thing... except we don't need ->setMaxResults() or ->setFirstResult(). Instead, add ->select('COUNT(recipe.id)'):

... lines 1 - 9
class LatestRecipeQueryTypeHandler implements QueryTypeHandlerInterface
{
... lines 12 - 28
public function getCount(Query $query): int
{
return $this->recipeRepository->createQueryBuilderOrderedByNewest()
->select('COUNT(recipe.id)')
->getQuery()
... line 34
}
... lines 36 - 40
}

I'm using recipe because, over in RecipeRepository... if we look at the custom method, it uses recipe as the alias in the query:

... lines 1 - 17
class RecipeRepository extends ServiceEntityRepository
{
... lines 20 - 42
public function createQueryBuilderOrderedByNewest(string $search = null): QueryBuilder
{
$queryBuilder = $this->createQueryBuilder('recipe')
... lines 46 - 53
}
}

After that, update ->getResult() to be ->getSingleScalarResult():

... lines 1 - 9
class LatestRecipeQueryTypeHandler implements QueryTypeHandlerInterface
{
... lines 12 - 28
public function getCount(Query $query): int
{
return $this->recipeRepository->createQueryBuilderOrderedByNewest()
->select('COUNT(recipe.id)')
->getQuery()
->getSingleScalarResult();
}
... lines 36 - 40
}

Phew! That was a bit of work, but fairly straightforward. Oh, and for isContextual(), return false:

... lines 1 - 9
class LatestRecipeQueryTypeHandler implements QueryTypeHandlerInterface
{
... lines 12 - 36
public function isContextual(Query $query): bool
{
return false;
}
}

We won't need it, but this method is kinda cool. If you return true, then you can read information from the current page to change the query - like if you were on a "category" page and needed to list only products in that category.

Tagging the Query Type Handler Class

Anyways, that's it. This is now a functional query type handler! But if you go over and refresh... it still doesn't work. We get the same error. That's because we need to associate this query type handler class with the latest_recipes query type in our config. To do that, we need to give the service a tag... and there's a really cool way to do this thanks to Symfony 6.1.

Above the class, add an attribute called #[AutoconfigureTag()]. The name of the tag we need is netgen_layouts.query_type_handler: this is straight out of the documentation. We also need to pass an array with a type key set to latest_recipes:

... lines 1 - 8
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('netgen_layouts.query_type_handler', ['type' => 'latest_recipes'])]
class LatestRecipeQueryTypeHandler implements QueryTypeHandlerInterface
{
... lines 14 - 42
}

This type must match what we have in our config:

netgen_layouts:
... lines 2 - 8
query_types:
latest_recipes:
... lines 11 - 12

It ties the two together.

And now... the page works! If we click on our Grid block... we can switch to "Dynamic collection". Awesome! I'll hit Apply and... everything immediately stops loading!

When you have an error in the admin section, there's a good chance it'll show up via an AJAX call. Often, Layouts will show you the error in a modal. But if it doesn't, no worries: just look down here on the web debug toolbar. Yup! We have a 400 error.

Let's fix that next by creating a value converter. Then we'll make our query even smarter.

Leave a comment!

9
Login or Register to join the conversation

Hey,
I am getting the same error "Query type handler for "latest_recipes" query type does not exist." even when I add into src/Layouts/LatestRecipeQueryTypeHandler.php:

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('netgen_layouts.query_type_handler', ['type' => 'latest_recipes'])]

and into config/packages/netgen_layouts.yaml:

netgen_layouts:
  pagelayout: 'base.html.twig'

  value_types:
    doctrine_recipe:
      name: Recipe
      manual_items: false

  query_types:
    latest_recipes:
        name: Latest Recipes
        

I don't know what is the problem... I need help. :)

Reply

Hey @StjepanPetrovic!

Hmm. To debug, let's first ask our app to show us all of the services that have the netgen_layouts.query_type_handler tag on it:

php bin/console debug:container --tag=netgen_layouts.query_type_handler

In my app, I see something like this:

 -------------------------------------------------------------------- ----------------------- ------------------------------------------------------------------------------------
  Service ID                                                           type                    Class name
 -------------------------------------------------------------------- ----------------------- ------------------------------------------------------------------------------------
  App\Layouts\LatestRecipeQueryTypeHandler                             latest_recipes          App\Layouts\LatestRecipeQueryTypeHandler
   (same service as previous, another tag)
  netgen_layouts.contentful.collection.query_type_handler.search       contentful_search       Netgen\Layouts\Contentful\Collection\QueryType\Handler\ContentfulSearchHandler
  netgen_layouts.contentful.collection.query_type.handler.references   contentful_references   Netgen\Layouts\Contentful\Collection\QueryType\Handler\ContentfulReferencesHandler
 -------------------------------------------------------------------- ----------------------- ------------------------------------------------------------------------------------

Do you see your service there? If not is your service being autowired (most are unless you have some special setup)? Does that AutoconfigureTag class exist in your app (I'm making sure your Symfony version is new enough for this).

Cheers!

Reply

Hey @weaverryan, here I am!

When I run: php bin/console debug:container --tag=netgen_layouts.query_type_handler I got the same error:
Query type handler for "latest_recipes" query type does not exist., so I don't see service here.

Also, when I run: php bin/console cache:clear I got the same error.

I have taken your starting source code and I am following steps in order like in your tutorials - you can check my code on my Github.
AutoconfigureTag exists in my app - my Symfony version is 5.4.22 and Symfony CLI version is 5.5.2.

Reply

Hey @StjepanPetrovic!

Ah, my apologies - I see now that the error comes from during container compilation! So you're right that even the debugging tools won't work to help us :). You can try this instead:

A) Comment-out the latest_recipes query type. Then verify that running bin/console cache:clear once again works. We're temporarily removing the problem so that our debugging tools work.

B) NOW try php bin/console debug:container --tag=netgen_layouts.query_type_handler. Do you see your service in this list?

You can also try running php bin/console debug:container LatestRecipeQueryTypeHandler - that should find your service (we're not giving the full service name, but it should search and find your service). Do you see it? And does the service definition have any tags on it?

Let me know!

Cheers!

Reply

Hey @weaverryan!

Somehow I got rid of that error... Here is the steps I did:

  • Like you said, I commented-out the latest_recipes query type. Everything was working now.
  • Like you said, I run php bin/console debug:container --tag=netgen_layouts.query_type_handler but I didn't saw my service in the list. Also I didn't find my servie with php bin/console debug:container LatestRecipeQueryTypeHandler
  • I uncomment the latest_recipes query type. Expected, I was getting the error.
  • Then I tried tagging the Query Type Handler Class on different way which I saw here in comment by @Tasatko. In config/services.yaml I added this code below:
      class: 'App\Layouts\LatestRecipeQueryTypeHandler'
      tags:
        - { type: 'latest_recipes', name: 'netgen_layouts.query_type_handler' }
    

    and in src/Layouts/LatestRecipeQueryTypeHandler.php I commented-out this:

    use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
    #[AutoconfigureTag('netgen_layouts.query_type_handler', ['type' => 'latest_recipes'])]
    

    After adding this I was not getting the error, everything was working and when I clicked on Grid block I could chose between manual or dynamic collection.

  • Because I wanted to do tagging with newest way, I again tried uncommenting:
    use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
    #[AutoconfigureTag('netgen_layouts.query_type_handler', ['type' => 'latest_recipes'])]
    

    and commenting-out:

      class: 'App\Layouts\LatestRecipeQueryTypeHandler'
      tags:
        - { type: 'latest_recipes', name: 'netgen_layouts.query_type_handler' }
    

aaaand in some reason everythink is working. I don't know what was the problem. :)

Reply

Haha, wow. That was very procedural to get that going! My best guess is that you had some phantom caching issue. When you create a new file/class, Symfony's container should notice the new class and rebuild the container so that the new class can be registered as a service. For some reason, I think that wasn't happening for you. By updating services.yaml, that triggered a container rebuild... where it probably, finally, noticed your new class/service. So, later, when you re-added the #[AutoconfigureTag] attribute, now it saw that and applied it.

Just a guess - I wouldn't worry too much about it - glad it's working!

Cheers!

1 Reply

Wow. Thanks to your lessons, I learn a lot about the Symfony itself. Some feature adds like year ago, but I didn't knew about.

#[AutoconfigureTag('netgen_layouts.query_type_handler', ['type' => 'latest_recipes'])]

instead of:

  LatestRecipeQueryTypeHandler:
    class: 'App\Layouts\LatestRecipeQueryTypeHandler'
    tags:
      - { type: 'latest_recipes', name: 'netgen_layouts.query_type_handler' }
Reply

Haha, I know, right? These features have been coming so fast and steady that it's hard to keep up (even for me, and it's my job). I LOVE AutoconfigureTag

Cheers!

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