Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Controller & Generating Admin URLs

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

The final step to building our custom EasyAdmin action is to... write the controller method! In QuestionCrudController, all the way down at the bottom, this will be a normal Symfony action. You can pretend like you're writing this in a non-EasyAdmin controller class with a route above it. Say public function approve(). When the user gets here, the id of the entity will be in the URL. To help read that, autowire AdminContext $adminContext.

<?php
... lines 3 - 159
public function approve()
{
}
}

Why are we allowed to add that argument? Because first, AdminContext is a service... just like the entity manager or the router. And second, the approve() method is a completely normal Symfony controller... so we're autowiring this service just like we would do with anything else.

Get the question with $question = $adminContext->getEntity()->getInstance(). And yes, sometimes, finding the data you need in AdminContext requires a little digging. Let's add a sanity check... (mostly for my editor): if (!$question instanceof Question), throw a new \LogicException('Entity is missing or not a Question'). Now, we can very easily say $question->setIsApproved(true).

<?php
... lines 3 - 159
public function approve(AdminContext $adminContext)
{
$question = $adminContext->getEntity()->getInstance();
if (!$question instanceof Question) {
throw new \LogicException('Entity is missing or not a Question');
}
$question->setIsApproved(true);
}
}

The last step is to save this entity... which looks completely normal! Autowire EntityManagerInterface $entityManager... and then add $entityManager->flush().

<?php
... lines 3 - 159
public function approve(AdminContext $adminContext, EntityManagerInterface $entityManager)
{
$question = $adminContext->getEntity()->getInstance();
if (!$question instanceof Question) {
throw new \LogicException('Entity is missing or not a Question');
}
$question->setIsApproved(true);
$entityManager->flush();
}
}

Rendering a Template

Sweet! Ok... but... what should we do after that? Well, we could render a template. Sometimes you'll create a custom action that is literally a new page in your admin section... and you would do that by rendering a template in a completely normal way. We already have an example of that inside DashboardController. The index() method is really a regular action... where we render a template. So if you wanted to render a template in a custom action, it would look pretty much exactly like this.

Generating an Admin Url

But in our situation, we want to redirect. And, we know how to do that from inside of a controller. But hmm, I want to redirect back to the "detail" page in the admin. In order to generate a URL to somewhere inside EasyAdmin, we need a special admin URL generator service that can help add the query parameters.

Let's autowire this: AdminUrlGenerator $adminUrlGenerator. Then $targetUrl =... and build the URL by saying $adminUrlGenerator, ->setController(self::class) - because we're going to link back to ourself - ->setAction(Crud::PAGE_DETAIL), ->setEntityId($question->getId())... and then finally, ->generateUrl().

There are a number of other methods you can call on this builder... but these are the most important. At the bottom return $this->redirect($targetUrl).

<?php
... lines 3 - 22
#[IsGranted('ROLE_MODERATOR')]
class QuestionCrudController extends AbstractCrudController
{
... lines 26 - 161
public function approve(AdminContext $adminContext, EntityManagerInterface $entityManager, AdminUrlGenerator $adminUrlGenerator)
{
$question = $adminContext->getEntity()->getInstance();
if (!$question instanceof Question) {
throw new \LogicException('Entity is missing or not a Question');
}
$question->setIsApproved(true);
$entityManager->flush();
$targetUrl = $adminUrlGenerator
->setController(self::class)
->setAction(Crud::PAGE_DETAIL)
->setEntityId($question->getId())
->generateUrl();
return $this->redirect($targetUrl);
}
}

Ok team, let's give this a try. Refresh and... got it! We're back on the detail page! And if we look for "Alice thought she might...", it's not on our "Pending Approval" page anymore!

Let's try one more to be sure: approve ID 23. Go to Show, click "Approve", and... it's gone. This is working!

Hiding Approve for Approved Question

The only weird thing now, which you probably saw, is that when you go to the detail page on an already-approved question... you still see the "Approve" button. Clicking on that doesn't hurt anything... but it's confusing! Fortunately, we know how to fix this.

Find your custom action... and add ->displayIf(). Pass that a static function(), which will receive the Question $question argument... and return a bool. I've been a little lazy on my return types, but you can put that if you want. Finally, return !$question->getIsApproved().

<?php
... lines 3 - 22
#[IsGranted('ROLE_MODERATOR')]
class QuestionCrudController extends AbstractCrudController
{
... lines 26 - 39
public function configureActions(Actions $actions): Actions
{
... lines 42 - 50
$approveAction = Action::new('approve')
->setTemplatePath('admin/approve_action.html.twig')
->linkToCrudAction('approve')
->addCssClass('btn btn-success')
->setIcon('fa fa-check-circle')
->displayAsButton()
->displayIf(static function (Question $question): bool {
return !$question->getIsApproved();
});
... lines 60 - 77
}
... lines 79 - 182
}

Move over now... refresh and... beautiful! The "Approve" button is gone. But when we go back to a question that does need to be approved, it's still there.

Custom Action JavaScript

If we wanted to, we could go further and write some JavaScript to make this fancier. For example, in our custom template, we could use the stimulus_controller function to reference a custom Stimulus controller. Then, when we click this button, we could, for example, open a modal that says:

Are you sure you want to approve this question?

The point is, we control what this action, link, button, etc. look like. If you want to attach some custom JavaScript, do it.

Next, let's add a global action. A "global action" is something that applies to all of the items inside of a section. We're going to create a global export action that exports questions to CSV.

Leave a comment!

10
Login or Register to join the conversation
Bernd Avatar

The tutorial is amazing! Thanks for the great work!

Minor note: I completely failed to add a custom action button similar to the "approve" button in my project within the EDIT page. After submitting, the error was that no "ea" key was found. After some code digging and attempts like building the twig template for the action manually without the include (and to mimic the other buttons) I worked around the error but the approve-action never got fired. I finally gave up and switched to the DETAIL page like in the video. And voilà - it worked.

Reply

Yo! @Bernd

Thanks for the feedback! Yeah sometimes creating something custom in EA is a challenge and it's easy to get lost, however it's cool that you found a way to achieve what you need!

Cheers!

Reply
Rudi-T Avatar

Trying to do that modal, but I'm stuck at the part how you can access current entity like displayIf does with a closure?
All idea's are welcome :)

$sendActivationAction = static fn(): Action => Action::new('sendActivation', 'Send activation mail')
            ->linkToCrudAction('sendActivationMail')
            ->setTemplatePath($postButtonTemplate)
            ->setHtmlAttributes([
                'data-confirmation-message' => sprintf(
                    'Do you want to send the activation e-mail for user "%s"?',
                    $user?->getUsername() // How to get the row's user?
                ),
            ])
            ->displayIf(fn(User $user) => $user->getStatus() === UserStatus::CREATED);
Reply

Hey Rudi-T,

Yeah, that's tricky. It's not something that could be done easily, unfortunately. But there're some workarounds. If you're on the PAGE_DETAIL of a User, you can fetch the related User from AdminContext, e.g:

        /** @var User $user */
        $user = $adminContext->getEntity()->getInstance();

just add dd($user) to debug that you get what you need at that point. You should be able to get that AdminContext instance by injecting the EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext service into your controller (or custom action). Or, in theory, it should be fetched from $this->getContext() call in the CRUD controller, but be aware that it may return null sometimes.

I hope this helps :)

Cheers!

Reply
Default user avatar
Default user avatar Speedyschmid | posted 1 year ago

Nice tutorial so far! I love it and EasyAdmin :D
Is there an easy way to generate an Admin URL with a preset filter?

For example, if I want to create different submenu items under the "Question" menu item, to list only the questions related to each topic
Questions
- All
- Pending Approval
- Topic 1
- Topic 2

and on click should the question index action be called, but with a filter for the chosen topic.
Or is there maybe another way to sort of create a nested CrudController?

Reply

Hey Speedyschmid,

Thanks for your feedback! We're really happy to hear you love the tutorial, and moreover the EasyAdminBundle :)

About your question, there's no easy way for this. Well, first of all because menu item and filter are different concepts, though they might provide a similar result. But if you want to do some shortcuts for the filters - you can do it I think, just build the proper URL with corresponding query parameters. To know which query params to use - go to the index page and apply the filter you want, then, look closer to the URL - you would need to build the same query params for the submenu link.

And it should work this way, though it's still not perfect probably, because if you're referencing to topics by ID - it won't work on both: production and local. Because most probably you will have different IDs on production :) But you can try and see if it fits your needs.

I hope this helps!

Cheers!

Reply
Default user avatar
Default user avatar Speedyschmid | Victor | posted 1 year ago | edited

Just wanted to reply myself, that I found a solution that works for my case - not with a preset filter, but a sort of nested Controller.

If anyone else is interested, here is how I do it:

  1. I first inject the TopicRepository inside the DashboardController with DI,
  2. I query for the Topics inside configureMenuItems()
  3. foreach ($topics as topic) and build an array for the SubMenuItems
    `
    $topics = $this->topicRepository->findAll();
    $questionTopicSubItems = [];
    foreach ($topics as $topic) {
    $questionTopicSubItems[] =
     MenuItem::linkToCrud($topic->getName(), 'fas fa-question-circle', Question::class)
         ->setController(QuestionCrudController::class)
         ->setQueryParameter('topicId', $topic->getId());
    

    }
    `

  4. Inside the QuestionCrudController inside createIndexQueryBuilder() i look for the query param and if exists, add the where condition
    `
    public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
    {

     $queryBuilder = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
    
     $request = $this->requestStack->getCurrentRequest();
     $topicId = $request?->query->get('topicId');
    
     if ($topicId) {
         $queryBuilder->andWhere('entity.topic = :topicId')
             ->setParameter('topicId', $topicId);
     }
    
     return $queryBuilder;
    

    }
    `

1 Reply

Hey Speedyschmid,

Good job finding the solution yourself! Well done! And thanks for sharing it with others ;)

Cheers!

1 Reply

Bravo! please keep going! my awaited tutorial is the next one! Can't wait to learn how to export data :)

Reply
sadikoff Avatar sadikoff | SFCASTS | Lubna | posted 1 year ago | edited

Hey Lubna

Yo! Thanks for the feedback we will do our best!

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": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/doctrine-bundle": "^2.1", // 2.5.5
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.2.1
        "doctrine/orm": "^2.7", // 2.10.4
        "easycorp/easyadmin-bundle": "^4.0", // v4.0.2
        "handcraftedinthealps/goodby-csv": "^1.4", // 1.4.0
        "knplabs/knp-markdown-bundle": "dev-symfony6", // dev-symfony6
        "knplabs/knp-time-bundle": "^1.11", // 1.17.0
        "sensio/framework-extra-bundle": "^6.0", // v6.2.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.7.0
        "symfony/asset": "6.0.*", // v6.0.1
        "symfony/console": "6.0.*", // v6.0.2
        "symfony/dotenv": "6.0.*", // v6.0.2
        "symfony/flex": "^2.0.0", // v2.0.1
        "symfony/framework-bundle": "6.0.*", // v6.0.2
        "symfony/mime": "6.0.*", // v6.0.2
        "symfony/monolog-bundle": "^3.0", // v3.7.1
        "symfony/runtime": "6.0.*", // v6.0.0
        "symfony/security-bundle": "6.0.*", // v6.0.2
        "symfony/stopwatch": "6.0.*", // v6.0.0
        "symfony/twig-bundle": "6.0.*", // v6.0.1
        "symfony/ux-chartjs": "^2.0", // v2.0.1
        "symfony/webpack-encore-bundle": "^1.7", // v1.13.2
        "symfony/yaml": "6.0.*", // v6.0.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.7
        "twig/twig": "^2.12|^3.0" // v3.3.7
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.1
        "symfony/debug-bundle": "6.0.*", // v6.0.2
        "symfony/maker-bundle": "^1.15", // v1.36.4
        "symfony/var-dumper": "6.0.*", // v6.0.2
        "symfony/web-profiler-bundle": "6.0.*", // v6.0.2
        "zenstruck/foundry": "^1.1" // v1.16.0
    }
}
userVoice